From cfa274825f393e179addcbbb7120eeb4eb8d51a8 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 7 Oct 2026 10:25:41 -0400 Subject: [PATCH] Refine terrain and pregeneration - Add coherent river width variation and shape source and terminal pools. - Preserve separated hydrology intervals when checking river connectivity. - Reduce footprint sorting, engine context lookup, and terrain blending work. - Reuse modded snapshot validation after content and registry checks. - Explain production pack update staging and restart requirements. - Retain pregeneration map updates before its desktop window opens. - Improve pregeneration layout, coordinate hover, keyboard controls, and error details. - Add the new desktop labels to every supported language catalog. - Add the buildPsychoLT entry point for verified platform outputs. - Cover terrain, validation, and desktop behavior with regression tests. --- .../ModdedGenerationHistoryStorage.java | 16 +- .../service/ModdedPackUpdateService.java | 3 +- .../ModdedPackValidationPublicationTest.java | 52 ++++ .../service/ModdedPackUpdateServiceTest.java | 3 + build.gradle | 6 + .../hydrology/HydrologyPlannerSettings.java | 2 +- .../HydrologyRegionalConnectivity.java | 85 ++++++- .../hydrology/HydrologyRegionalPlanner.java | 17 +- .../IrisSurfaceRiverChannelConfig.java | 8 +- .../surface/ChannelProfileBuilder.java | 38 ++- .../surface/ErosionFieldCompiler.java | 39 +-- .../surface/SurfaceCourseBuilder.java | 2 +- .../surface/SurfaceFootprintCompiler.java | 32 ++- .../iris/generation/noise/IrisGenerator.java | 53 ++-- .../generation/noise/IrisGeneratorStyle.java | 6 +- .../generation/noise/IrisNoiseGenerator.java | 19 +- .../iris/localization/DesktopUiMessages.java | 3 + .../iris/studio/view/PregenMapPanel.java | 93 +++---- .../iris/studio/view/PregenMapState.java | 123 +++++++++ .../iris/studio/view/PregenRenderSource.java | 2 + .../iris/studio/view/PregenRenderer.java | 235 ++++++++++++++---- .../iris/studio/view/PregeneratorJob.java | 30 +-- .../world/history/BoundaryColumnGeometry.java | 20 +- core/src/main/resources/languages/de_DE.toml | 2 + core/src/main/resources/languages/es_ES.toml | 2 + core/src/main/resources/languages/fi_FI.toml | 2 + core/src/main/resources/languages/fr_FR.toml | 2 + core/src/main/resources/languages/he_IL.toml | 2 + core/src/main/resources/languages/it_IT.toml | 2 + core/src/main/resources/languages/ja-JP.toml | 2 + core/src/main/resources/languages/ko_KR.toml | 2 + core/src/main/resources/languages/lt_LT.toml | 2 + core/src/main/resources/languages/nl_NL.toml | 2 + core/src/main/resources/languages/pl_PL.toml | 2 + core/src/main/resources/languages/pt_PT.toml | 2 + core/src/main/resources/languages/ru_RU.toml | 2 + core/src/main/resources/languages/tr_TR.toml | 2 + core/src/main/resources/languages/vi_VI.toml | 2 + core/src/main/resources/languages/zh_CN.toml | 2 + core/src/main/resources/languages/zh_TW.toml | 2 + .../hydrology/HydrologyPlanHashTest.java | 2 +- .../HydrologyRegionalConnectivityTest.java | 123 +++++++++ .../HydrologyRegionalMorphologyTest.java | 17 ++ .../HydrologyRegionalWidthAdaptationTest.java | 8 +- .../surface/ChannelProfileBuilderTest.java | 165 +++++++++--- .../surface/ErosionFieldCompilerTest.java | 105 ++++++-- .../surface/SurfaceFootprintOrderingTest.java | 44 ++++ .../surface/ValleyProfileSolverTest.java | 6 +- .../noise/IrisGeneratorSurfaceDetailTest.java | 4 +- .../noise/IrisNoiseGeneratorCacheTest.java | 68 ++++- .../schema/SchemaBuilderIdentityTest.java | 2 +- .../iris/studio/view/PregenMapPanelTest.java | 56 ++++- .../studio/view/PregenMapRetentionTest.java | 164 ++++++++++++ .../iris/studio/view/PregenRendererTest.java | 200 +++++++++++++++ .../view/PregeneratorJobSavedBiomeTest.java | 7 +- .../history/BoundaryColumnGeometryTest.java | 44 ++++ 56 files changed, 1656 insertions(+), 280 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/studio/view/PregenMapState.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintOrderingTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/view/PregenMapRetentionTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/view/PregenRendererTest.java diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java index 41bb69213..bb8f2f906 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java @@ -6,10 +6,9 @@ import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.pack.BrokenPackException; -import art.arcane.iris.pack.PackValidationCache; +import art.arcane.iris.pack.PackFingerprints; import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationResult; -import art.arcane.iris.pack.PackValidator; import art.arcane.iris.world.history.GenerationEpoch; import art.arcane.iris.world.history.GenerationEpochContractFactory; import art.arcane.iris.world.history.GenerationHistory; @@ -174,7 +173,7 @@ final class ModdedGenerationHistoryStorage { static ActivePack resolveActive(GenerationHistory history) throws IOException { GenerationEpoch epoch = history.activeEpoch(); Path packRoot = normalize(history.activePackRoot()); - validatePack(packRoot, epoch.packFingerprint()); + validatePack(packRoot); validateDimension(packRoot, epoch); return new ActivePack( packRoot, @@ -184,13 +183,16 @@ final class ModdedGenerationHistoryStorage { ); } - private static void validatePack(Path packRoot, String fingerprint) { - String contextFingerprint = PackValidationCache.contextFingerprint(); - PackValidationResult validation = PackValidator.validate(packRoot.toFile()); - PackValidationRegistry.publish(packRoot, validation, fingerprint, contextFingerprint); + static PackValidationResult validatePack(Path packRoot) { + PackValidationResult validation = PackValidationRegistry.getMatching( + packRoot, PackFingerprints.computePackTreeFingerprint(packRoot.toFile())); + if (validation == null) { + validation = ModdedStartup.validatePack(packRoot.toFile()); + } if (!validation.isLoadable()) { throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors()); } + return validation; } private static void validateDimension( diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPackUpdateService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPackUpdateService.java index 056f5ec36..e65eebdfe 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPackUpdateService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPackUpdateService.java @@ -119,7 +119,8 @@ public final class ModdedPackUpdateService implements ModdedTickableService { + ", latest built-in release v" + update.version() + "."); updates.add("Back up and review custom edits in " + new File(packsFolder, pack.name()) + "; close Studio, then update with /iris download pack=" + pack.name() + " overwrite=true."); - updates.add("Existing worlds retain their recorded production pack; this updates the authoring pack for new worlds."); + updates.add("Existing worlds retain their recorded production pack. Back up the complete world, then stage an update with /iris world update " + + pack.name() + " and restart the server to activate it."); } } notices = List.copyOf(updates); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackValidationPublicationTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackValidationPublicationTest.java index c32b08189..5d1b120ca 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackValidationPublicationTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackValidationPublicationTest.java @@ -3,6 +3,7 @@ package art.arcane.iris.modded; import art.arcane.iris.modded.command.ModdedPackCommands; import art.arcane.iris.pack.BrokenPackException; import art.arcane.iris.pack.PackValidationRegistry; +import art.arcane.iris.pack.PackValidationCache; import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidator; import org.junit.After; @@ -15,6 +16,7 @@ import java.io.File; import java.nio.file.Files; import java.nio.file.attribute.FileTime; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; @@ -22,6 +24,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.fail; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; public class ModdedPackValidationPublicationTest { @Rule @@ -32,6 +35,55 @@ public class ModdedPackValidationPublicationTest { PackValidationRegistry.clear(); } + @Test + public void repeatedSnapshotValidationParsesUnchangedPackOnce() throws Exception { + File root = temporaryFolder.newFolder("snapshot"); + PackValidationResult result = loadable(root); + try (MockedStatic validator = mockStatic(PackValidator.class)) { + validator.when(() -> PackValidator.validate(root)).thenReturn(result); + assertSame(result, ModdedGenerationHistoryStorage.validatePack(root.toPath())); + assertSame(result, ModdedGenerationHistoryStorage.validatePack(root.toPath())); + validator.verify(() -> PackValidator.validate(root), times(1)); + } + } + + @Test + public void snapshotValidationRejectsChangedBytesWithPreservedTimestamp() throws Exception { + File root = temporaryFolder.newFolder("snapshot"); + File content = new File(root, "content.json"); + Files.writeString(content.toPath(), "valid"); + FileTime timestamp = Files.getLastModifiedTime(content.toPath()); + PackValidationResult broken = new PackValidationResult(root.getName(), List.of("invalid"), List.of(), 1L); + try (MockedStatic validator = mockStatic(PackValidator.class)) { + validator.when(() -> PackValidator.validate(root)).thenReturn(loadable(root), broken); + ModdedGenerationHistoryStorage.validatePack(root.toPath()); + Files.writeString(content.toPath(), "wrong"); + Files.setLastModifiedTime(content.toPath(), timestamp); + assertThrows(BrokenPackException.class, + () -> ModdedGenerationHistoryStorage.validatePack(root.toPath())); + assertThrows(BrokenPackException.class, + () -> ModdedGenerationHistoryStorage.validatePack(root.toPath())); + validator.verify(() -> PackValidator.validate(root), times(2)); + } + } + + @Test + public void snapshotValidationReparsesChangedRegistryContext() throws Exception { + File root = temporaryFolder.newFolder("snapshot"); + AtomicReference context = new AtomicReference<>("first"); + PackValidationResult first = loadable(root); + PackValidationResult second = loadable(root); + try (MockedStatic validator = mockStatic(PackValidator.class); + MockedStatic cache = mockStatic(PackValidationCache.class)) { + cache.when(PackValidationCache::contextFingerprint).thenAnswer(ignored -> context.get()); + validator.when(() -> PackValidator.validate(root)).thenReturn(first, second); + assertSame(first, ModdedGenerationHistoryStorage.validatePack(root.toPath())); + context.set("second"); + assertSame(second, ModdedGenerationHistoryStorage.validatePack(root.toPath())); + validator.verify(() -> PackValidator.validate(root), times(2)); + } + } + @Test public void validationPublishesTheCanonicalRootAndPackName() throws Exception { File root = temporaryFolder.newFolder("terrain"); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedPackUpdateServiceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedPackUpdateServiceTest.java index 3c6c116b1..d5aba1aff 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedPackUpdateServiceTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedPackUpdateServiceTest.java @@ -65,6 +65,9 @@ public class ModdedPackUpdateServiceTest { verify(online, times(1)).sendMessage(contains("installed v1, latest built-in release v2")); verify(online, times(1)).sendMessage(contains(new File(root, "overworld").toString())); verify(online, times(1)).sendMessage(contains("/iris download pack=overworld overwrite=true")); + verify(online, times(1)).sendMessage(contains("Back up the complete world")); + verify(online, times(1)).sendMessage(contains("/iris world update overworld")); + verify(online, times(1)).sendMessage(contains("restart the server to activate it")); verify(online, never()).sendMessage(contains("plugins/")); NativeProtocolPlayer later = player(true, false); service.notifyPlayer(later); diff --git a/build.gradle b/build.gradle index 56203149b..dc003ae71 100644 --- a/build.gradle +++ b/build.gradle @@ -775,6 +775,12 @@ tasks.register('buildAll') { } } +tasks.register('buildPsychoLT') { + group = 'development' + description = 'Builds all verified Iris platform jars and stages them in the local test-server dropins and PluginOuts.' + dependsOn('buildAll') +} + tasks.register('irisDev', Copy) { group = 'iris' from(project(':core').layout.buildDirectory.files('libs/core-javadoc.jar', 'libs/core-sources.jar')) diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSettings.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSettings.java index 6c4b3ed9c..6abff3fd9 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSettings.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSettings.java @@ -589,7 +589,7 @@ public record HydrologyPlannerSettings( } } - /** A pond at one end of a surface course: a round bowl holding the course's head at that end. */ + /** A pond at one end of a surface course: a shaped bowl holding the course's head at that end. */ public record Pond(boolean enabled, int minimumRadius, int maximumRadius, int depth) { public Pond { if (minimumRadius < 1 || maximumRadius < minimumRadius || depth < 1) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalConnectivity.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalConnectivity.java index d993b54b0..f1713cfb1 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalConnectivity.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalConnectivity.java @@ -2,6 +2,8 @@ package art.arcane.iris.generation.hydrology; import art.arcane.iris.generation.hydrology.surface.SurfaceCenterline; import art.arcane.iris.generation.hydrology.surface.SurfaceLayerColumn; +import it.unimi.dsi.fastutil.ints.IntArrayFIFOQueue; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import it.unimi.dsi.fastutil.longs.LongArrayList; @@ -71,10 +73,16 @@ final class HydrologyRegionalConnectivity { } boolean connected(RiverCourse course, HydrologyTerrainSampler sampler, HydrologyPlannerSettings settings) { - if (water.isEmpty() || !disjoint.isEmpty()) { + if (water.isEmpty()) { return false; } LongOpenHashSet visited = new LongOpenHashSet(water.size()); + if (!disjoint.isEmpty()) { + if (!connectedIntervals(visited)) { + return false; + } + return reachesTerminal(course, sampler, settings, visited); + } LongArrayFIFOQueue queue = new LongArrayFIFOQueue(); long start = water.keySet().iterator().nextLong(); queue.enqueue(start); @@ -102,6 +110,65 @@ final class HydrologyRegionalConnectivity { if (visited.size() != water.size()) { return false; } + return reachesTerminal(course, sampler, settings, visited); + } + + private boolean connectedIntervals(LongOpenHashSet visited) { + int count = water.size(); + for (LongArrayList intervals : disjoint.values()) { + count += intervals.size() - 1; + } + long[] columns = new long[count]; + long[] intervals = new long[count]; + Long2IntOpenHashMap starts = new Long2IntOpenHashMap(water.size()); + starts.defaultReturnValue(-1); + int index = 0; + for (long column : water.keySet()) { + starts.put(column, index); + LongArrayList layers = disjoint.get(column); + int size = layers == null ? 1 : layers.size(); + for (int layer = 0; layer < size; layer++) { + columns[index] = column; + intervals[index++] = layers == null ? water.get(column) : layers.getLong(layer); + } + } + boolean[] reached = new boolean[count]; + IntArrayFIFOQueue queue = new IntArrayFIFOQueue(); + queue.enqueue(0); + reached[0] = true; + visited.add(columns[0]); + int reachedCount = 1; + while (!queue.isEmpty()) { + int current = queue.dequeueInt(); + int currentBed = (int) (intervals[current] >> 32); + int currentHead = (int) intervals[current]; + int x = RiverFootprint.unpackX(columns[current]); + int z = RiverFootprint.unpackZ(columns[current]); + for (int[] offset : NEIGHBORS) { + long nextColumn = RiverFootprint.pack(x + offset[0], z + offset[1]); + int start = starts.get(nextColumn); + if (start < 0) { + continue; + } + LongArrayList layers = disjoint.get(nextColumn); + int end = start + (layers == null ? 1 : layers.size()); + for (int next = start; next < end; next++) { + if (reached[next] || Math.max(currentBed, (int) (intervals[next] >> 32)) + >= Math.min(currentHead, (int) intervals[next])) { + continue; + } + reached[next] = true; + reachedCount++; + visited.add(nextColumn); + queue.enqueue(next); + } + } + } + return reachedCount == count; + } + + private boolean reachesTerminal(RiverCourse course, HydrologyTerrainSampler sampler, + HydrologyPlannerSettings settings, LongOpenHashSet visited) { HydraulicSegment first = course.segments().getFirst(); HydraulicSegment last = course.segments().getLast(); return reaches(last, false, sampler, settings, visited) @@ -154,8 +221,20 @@ final class HydrologyRegionalConnectivity { private boolean touchesOwnedWater(int x, int z, int seaLevel, LongOpenHashSet visited) { for (int[] offset : NEIGHBORS) { long key = RiverFootprint.pack(x + offset[0], z + offset[1]); - if (visited.contains(key) && (int) water.get(key) == seaLevel) { - return true; + if (!visited.contains(key)) { + continue; + } + LongArrayList layers = disjoint.get(key); + if (layers == null) { + if ((int) water.get(key) == seaLevel) { + return true; + } + continue; + } + for (int index = 0; index < layers.size(); index++) { + if ((int) layers.getLong(index) == seaLevel) { + return true; + } } } return false; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java index 247e982a4..6d19e3978 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java @@ -31,6 +31,7 @@ final class HydrologyRegionalPlanner { private static final long BASIN_SALT = 0x524547424153494eL; private static final long COURSE_SALT = 0x524547434f555253L; private static final long NODE_SALT = 0x5245474e4f444553L; + private static final double AUTHORED_WIDTH_WEIGHT = 0.65D; private static final int MAXIMUM_SOURCE_TRIALS = 24; private static final int SOURCE_TRIAL_WINDOW = 16; private static final int MAXIMUM_DIAGNOSTICS = 64; @@ -579,11 +580,17 @@ final class HydrologyRegionalPlanner { if (request.field() != HydrologyGeometrySampler.Field.SURFACE_WIDTH) { return planner.geometrySampler.sample(request); } - int authored = planner.geometrySampler.sample(request); - double fraction = 0.25D + 0.75D * context.flow().fraction(request.x(), request.z()); - double sampled = request.minimum() + (request.maximum() - request.minimum()) * fraction; - double width = sampled * 0.75D + authored * 0.25D; - double freedom = context.morphology().freedomAt(request.x(), request.z()); + return regionalWidth(request, planner.geometrySampler.sample(request), + context.flow().fraction(request.x(), request.z()), + context.morphology().freedomAt(request.x(), request.z())); + } + + static int regionalWidth(HydrologyGeometrySampler.Request request, int authored, + double flow, double freedom) { + double fraction = 0.25D + 0.75D * flow; + double range = (double) request.maximum() - request.minimum(); + double sampled = request.minimum() + range * fraction; + double width = sampled * (1D - AUTHORED_WIDTH_WEIGHT) + authored * AUTHORED_WIDTH_WEIGHT; double constrained = request.minimum() + (width - request.minimum()) * freedom; return Math.max(request.minimum(), Math.min(request.maximum(), (int) StrictMath.round(constrained))); } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/IrisSurfaceRiverChannelConfig.java b/core/src/main/java/art/arcane/iris/generation/hydrology/IrisSurfaceRiverChannelConfig.java index de43ad06b..14292ce79 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/IrisSurfaceRiverChannelConfig.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/IrisSurfaceRiverChannelConfig.java @@ -16,7 +16,7 @@ import lombok.experimental.Accessors; @Description("Controls the wet channel of a surface river.") @Data public class IrisSurfaceRiverChannelConfig { - @Description("Wet channel width in blocks.") + @Description("Wet channel width range in blocks. Authored noise and coherent reach variation shape the width; equal endpoints disable the additional variation.") private IrisStyledRange width = range(4D, 8D, 1024D); @Description("Wet bed depth in blocks at the channel center.") @@ -44,17 +44,17 @@ public class IrisSurfaceRiverChannelConfig { @MinNumber(1) @MaxNumber(4) - @Description("Width of the spring pool at the headwater relative to the channel width; 1 starts the river at its normal width.") + @Description("Peak width of the spring pool relative to the channel width, reached near the first quarter of springLength; 1 keeps the source at normal channel width.") private double springWidthRatio = 2.5D; @MinNumber(4) @MaxNumber(96) - @Description("Blocks over which the spring pool narrows back to the channel width.") + @Description("Blocks over which the source widens into its spring pool and narrows back to the channel width.") private int springLength = 24; @MinNumber(0) @MaxNumber(64) - @Description("Stations along the course the sampled width and depth are averaged over, so the channel changes size gradually; 0 follows the sampled values exactly at every station.") + @Description("Stations along the course the varied width and sampled depth are averaged over; 0 retains their unsmoothed values at each station.") private int smoothingRadius = 16; @MinNumber(0.2) diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ChannelProfileBuilder.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ChannelProfileBuilder.java index fcfae83fa..acb5d6b0b 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ChannelProfileBuilder.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ChannelProfileBuilder.java @@ -1,6 +1,7 @@ package art.arcane.iris.generation.hydrology.surface; import art.arcane.iris.generation.hydrology.HydrologyGeometrySampler; +import art.arcane.iris.generation.hydrology.HydrologyHash; import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings; import art.arcane.iris.generation.hydrology.HydrologyTerrainSample; import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler; @@ -8,6 +9,13 @@ import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler; import java.util.Objects; public final class ChannelProfileBuilder { + private static final long WIDTH_REACH_SALT = 0x5749445448524541L; + private static final double WIDTH_REACH_STRENGTH = 0.45D; + private static final double SPRING_OPENING_FRACTION = 0.6D; + private static final double SPRING_PEAK_PROGRESS = 0.25D; + private static final int MINIMUM_WIDTH_WAVELENGTH = 32; + private static final int MAXIMUM_WIDTH_WAVELENGTH = 128; + private final HydrologyPlannerSettings.Surface surface; private final HydrologyTerrainSampler sampler; private final HydrologyGeometrySampler geometry; @@ -25,13 +33,18 @@ public final class ChannelProfileBuilder { public ChannelProfile build( SurfaceCenterline centerline, String profileKey, - boolean directOcean + boolean directOcean, + long worldSeed ) { + profileKey = profileKey == null ? "" : profileKey; int count = centerline.size(); double[] width = new double[count]; double[] depth = new double[count]; double[] bank = new double[count]; int coast = count; + long widthSeed = HydrologyHash.mix(worldSeed, WIDTH_REACH_SALT, profileKey.hashCode()); + int widthWavelength = Math.max(MINIMUM_WIDTH_WAVELENGTH, + Math.min(MAXIMUM_WIDTH_WAVELENGTH, surface.maximumWidth() * 12)); for (int station = 0; station < count; station++) { int stationX = centerline.x()[station]; int stationZ = centerline.z()[station]; @@ -60,7 +73,12 @@ public final class ChannelProfileBuilder { double widthMultiplier = terrain == null ? 1D : terrain.widthMultiplier(); double depthMultiplier = terrain == null ? 1D : terrain.depthMultiplier(); bank[station] = terrain == null ? 1D : terrain.bankMultiplier(); - width[station] = clamp(sampledWidth * widthMultiplier, surface.minimumWidth(), surface.maximumWidth() * 2D); + double variedWidth = sampledWidth; + if (surface.maximumWidth() > surface.minimumWidth() && sampledWidth > surface.minimumWidth()) { + variedWidth = reachWidth(sampledWidth, surface.minimumWidth(), surface.maximumWidth(), + SurfaceNoise.signed(widthSeed, stationX, stationZ, widthWavelength)); + } + width[station] = clamp(variedWidth * widthMultiplier, surface.minimumWidth(), surface.maximumWidth() * 2D); depth[station] = clamp(sampledDepth * depthMultiplier, 1D, surface.maximumDepth() * 2D); } HydrologyPlannerSettings.Channel channel = surface.banks().channel(); @@ -74,7 +92,12 @@ public final class ChannelProfileBuilder { for (int station = 0; station < spring; station++) { double remaining = 1D - SurfaceNoise.smoothStep(station / (double) spring); double localRatio = 1D + (springRatio - 1D) * springRoom(centerline, station, smoothWidth[station] * springRatio / 2D); - smoothWidth[station] *= 1D + (localRatio - 1D) * remaining; + double progress = station / (double) spring; + double opening = progress <= SPRING_PEAK_PROGRESS + ? SPRING_OPENING_FRACTION + (1D - SPRING_OPENING_FRACTION) + * SurfaceNoise.smoothStep(progress / SPRING_PEAK_PROGRESS) + : 1D - SurfaceNoise.smoothStep((progress - SPRING_PEAK_PROGRESS) / (1D - SPRING_PEAK_PROGRESS)); + smoothWidth[station] *= 1D + (localRatio - 1D) * opening; smoothDepth[station] += remaining * channel.springExtraDepth(); } // The inlet: over its length before the coast the channel widens toward the mouth flare and @@ -146,6 +169,15 @@ public final class ChannelProfileBuilder { return smoothed; } + static double reachWidth(double authored, int minimum, int maximum, double reach) { + double range = (double) maximum - minimum; + if (range <= 0D || authored <= minimum) { + return authored; + } + double freedom = Math.min(1D, (authored - minimum) / (range * 0.25D)); + return clamp(authored + range * WIDTH_REACH_STRENGTH * freedom * reach, minimum, maximum); + } + private static double clamp(double value, double minimum, double maximum) { return Math.max(minimum, Math.min(maximum, value)); } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java index deea5519d..ff4483649 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java @@ -28,6 +28,11 @@ public final class ErosionFieldCompiler { private static final long SOURCE_POND_SALT = 0x504f4e4453524345L; private static final long TERMINAL_POND_SALT = 0x504f4e4454524dL; private static final int POND_RIM_SAMPLES_PER_BLOCK = 8; + private static final long POND_ASPECT_SALT = 0x504f4e4441535045L; + private static final double POND_LONG_AXIS_RATIO = 0.85D; + private static final double POND_OFFSET_RATIO = 0.15D; + private static final double POND_MINIMUM_SHORT_AXIS_RATIO = 0.6D; + private static final double POND_SHORT_AXIS_RANGE = 0.2D; private static final double EPSILON = 1.0E-9D; private final HydrologyPlannerSettings.Surface surface; @@ -174,22 +179,20 @@ public final class ErosionFieldCompiler { continue; } long key = RiverFootprint.pack(cellX, cellZ); - int index = indices.get(key); - int existing = index < 0 ? -1 : stations.getInt(index); - double existingDistance = index < 0 ? 0D : distance.getDouble(index); + int index = indices.putIfAbsent(key, stations.size()); + if (index < 0) { + stations.add(station); + distance.add(cellDistance); + continue; + } + int existing = stations.getInt(index); + double existingDistance = distance.getDouble(index); // A cell on a station's cross-section row ties between the two segments meeting // there; the later station owns it so the row uses the head its own ring bounded. - if (existing < 0 - || cellDistance < existingDistance - EPSILON + if (cellDistance < existingDistance - EPSILON || Math.abs(cellDistance - existingDistance) <= EPSILON && station > existing) { - if (index < 0) { - indices.put(key, stations.size()); - stations.add(station); - distance.add(cellDistance); - } else { - stations.set(index, station); - distance.set(index, cellDistance); - } + stations.set(index, station); + distance.set(index, cellDistance); } } } @@ -352,7 +355,7 @@ public final class ErosionFieldCompiler { } /** - * A round bowl around an end station holding that station's head: wet inside its outline, a shore + * A shaped bowl around an end station holding that station's head: wet inside its outline, a shore * ring and an eroded rim outside it like the channel's own. Channel water inside the bowl joins * the pond at the pond's level; dry channel columns give way to the bowl. The radius is chosen per * course within the configured range and shrinks where the ground around the rim falls below the @@ -399,9 +402,15 @@ public final class ErosionFieldCompiler { double maximumBlendWidth = Math.min(banks.maximumBlendWidth(), Math.max(0.25D, erosion.excavation().maximumWidth() - shore)); int reach = (int) StrictMath.ceil(radius * (1D + roughness) + shore + banks.maximumBlendWidth()) + 1; + double tangentX = centerline.tangentX()[station]; + double tangentZ = centerline.tangentZ()[station]; + double offset = radius * POND_OFFSET_RATIO; + double minor = POND_MINIMUM_SHORT_AXIS_RATIO + POND_SHORT_AXIS_RANGE * HydrologyHash.unit(HydrologyHash.mix(seed, POND_ASPECT_SALT)); for (int deltaZ = -reach; deltaZ <= reach; deltaZ++) { for (int deltaX = -reach; deltaX <= reach; deltaX++) { - double distance = Math.sqrt((double) deltaX * deltaX + (double) deltaZ * deltaZ); + double along = (deltaX * tangentX + deltaZ * tangentZ - offset) / POND_LONG_AXIS_RATIO; + double across = (-deltaX * tangentZ + deltaZ * tangentX) / minor; + double distance = Math.sqrt(along * along + across * across); if (distance > reach) { continue; } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceCourseBuilder.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceCourseBuilder.java index ba9e23c1d..61cd3ab22 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceCourseBuilder.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceCourseBuilder.java @@ -47,7 +47,7 @@ public final class SurfaceCourseBuilder { SurfaceCenterline centerline = SurfaceCenterline.densify(path); boolean directOcean = terminal == SurfaceTerminal.OCEAN_MOUTH; ChannelProfile channel = new ChannelProfileBuilder(surface, sampler, geometry) - .build(centerline, profileKey, directOcean); + .build(centerline, profileKey, directOcean, worldSeed); ValleyProfile valley = new RiverProfileSolver(new RiverProfileSolver.Options(surface, seaLevel), sampler) .solve(path, centerline, channel, terminal, terminalHead, minimumCourseLength); if (!valley.accepted()) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java index 7a6aab149..47d2c6323 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java @@ -21,12 +21,15 @@ import it.unimi.dsi.fastutil.ints.IntArrayList; import it.unimi.dsi.fastutil.doubles.DoubleArrayList; import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.Objects; public final class SurfaceFootprintCompiler { private static final long COURSE_SEED_SALT = 0x535552464143455fL; + private static final Comparator COLUMN_POSITION_ORDER = Comparator.comparingLong( + column -> RiverFootprint.pack(column.x(), column.z())); private final HydrologyPlannerSettings settings; private final HydrologyTerrainSampler sampler; @@ -175,11 +178,9 @@ public final class SurfaceFootprintCompiler { seed, centerline, run.channel(), run.valley(), run.terminal(), settings.outlets().maximumOceanApron(), run.ponds(), context, run.field()); - ArrayList ordered = new ArrayList<>(field.columns().values()); - ordered.sort(Comparator.comparingInt(SurfaceColumn::station) - .thenComparingLong((SurfaceColumn column) -> RiverFootprint.pack(column.x(), column.z()))); + SurfaceColumn[] ordered = orderedColumns(field, centerline.size()); SurfaceFeatureRefs features = new SurfaceFeatureRefs(course.id()); - ArrayList columns = new ArrayList<>(ordered.size()); + ArrayList columns = new ArrayList<>(ordered.length); for (SurfaceColumn column : ordered) { if (bounds != null && !bounds.contains(column.x(), column.z())) { continue; @@ -199,6 +200,29 @@ public final class SurfaceFootprintCompiler { return new SurfaceFootprint(columns, field.uncontainedWetCells(), field.rejection(), field.rejectionDetail(), field.bankExcavation()); } + static SurfaceColumn[] orderedColumns(ErosionField field, int stationCount) { + int[] ends = new int[stationCount + 1]; + for (SurfaceColumn column : field.columns().values()) { + ends[column.station() + 1]++; + } + for (int station = 1; station < ends.length; station++) { + ends[station] += ends[station - 1]; + } + SurfaceColumn[] ordered = new SurfaceColumn[field.size()]; + for (SurfaceColumn column : field.columns().values()) { + ordered[ends[column.station()]++] = column; + } + int start = 0; + for (int station = 0; station < stationCount; station++) { + int end = ends[station]; + if (end - start > 1) { + Arrays.sort(ordered, start, end, COLUMN_POSITION_ORDER); + } + start = end; + } + return ordered; + } + private SurfaceRunBoundary runBoundary(RiverCourse course, int first, int after, SurfaceCenterline centerline, ChannelProfile channel) { SurfaceRunBoundary.Plane start = null; diff --git a/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java b/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java index 3b75b7578..7d0a33444 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java @@ -200,38 +200,37 @@ public class IrisGenerator extends IrisRegistrant { if (!Double.isFinite(surfaceDetail) || surfaceDetail < 0D || surfaceDetail > 1D) { throw new IllegalArgumentException("Generator surfaceDetail must be finite and between 0 and 1"); } - if (surfaceDetail == 1D || composite.isEmpty()) { - return sampleHeight(rx, rz, superSeed); + if (composite.isEmpty()) { + return 0D; } IrisData data = getLoader(); Engine engine = data == null ? null : data.getEngine(); + if (surfaceDetail == 1D) { + return sampleHeight(rx, rz, superSeed, data, engine); + } SurfaceCacheStripe[] stripes = surfaceCache(data, engine).stripes(); double lowerX = Math.floor(rx / SURFACE_GRID) * SURFACE_GRID; double lowerZ = Math.floor(rz / SURFACE_GRID) * SURFACE_GRID; double dx = (rx - lowerX) / SURFACE_GRID; double dz = (rz - lowerZ) / SURFACE_GRID; - double northWest = cachedSurfaceHeight(lowerX, lowerZ, superSeed, stripes); + double northWest = cachedSurfaceHeight(lowerX, lowerZ, superSeed, stripes, data, engine); if (dx == 0D && dz == 0D) { return northWest; } double north = dx == 0D ? northWest : IrisInterpolation.lerp(northWest, - cachedSurfaceHeight(lowerX + SURFACE_GRID, lowerZ, superSeed, stripes), dx); + cachedSurfaceHeight(lowerX + SURFACE_GRID, lowerZ, superSeed, stripes, data, engine), dx); double smooth = north; if (dz != 0D) { - double southWest = cachedSurfaceHeight(lowerX, lowerZ + SURFACE_GRID, superSeed, stripes); + double southWest = cachedSurfaceHeight(lowerX, lowerZ + SURFACE_GRID, superSeed, stripes, data, engine); double south = dx == 0D ? southWest : IrisInterpolation.lerp(southWest, - cachedSurfaceHeight(lowerX + SURFACE_GRID, lowerZ + SURFACE_GRID, superSeed, stripes), dx); + cachedSurfaceHeight(lowerX + SURFACE_GRID, lowerZ + SURFACE_GRID, superSeed, stripes, data, engine), dx); smooth = IrisInterpolation.lerp(north, south, dz); } return surfaceDetail == 0D ? smooth - : smooth + (sampleHeight(rx, rz, superSeed) - smooth) * surfaceDetail; + : smooth + (sampleHeight(rx, rz, superSeed, data, engine) - smooth) * surfaceDetail; } - private double sampleHeight(double rx, double rz, long superSeed) { - if (composite.isEmpty()) { - return 0; - } - + private double sampleHeight(double rx, double rz, long superSeed, IrisData data, Engine engine) { int hc = (int) ((cliffHeightMin * 10) + 10 + cliffHeightMax * getSeed() + offsetX + offsetZ); double h = multiplicitive ? 1 : 0; double tp = 0; @@ -240,18 +239,18 @@ public class IrisGenerator extends IrisRegistrant { if (composite.size() == 1) { if (multiplicitive) { - h *= composite.get(0).getNoise(getSeed() + superSeed + hc, sampleX, sampleZ, getLoader()); + h *= composite.get(0).getNoise(getSeed() + superSeed + hc, sampleX, sampleZ, data, engine); } else { tp += composite.get(0).getOpacity(); - h += composite.get(0).getNoise(getSeed() + superSeed + hc, sampleX, sampleZ, getLoader()); + h += composite.get(0).getNoise(getSeed() + superSeed + hc, sampleX, sampleZ, data, engine); } } else { for (IrisNoiseGenerator i : composite) { if (multiplicitive) { - h *= i.getNoise(getSeed() + superSeed + hc, sampleX, sampleZ, getLoader()); + h *= i.getNoise(getSeed() + superSeed + hc, sampleX, sampleZ, data, engine); } else { tp += i.getOpacity(); - h += i.getNoise(getSeed() + superSeed + hc, sampleX, sampleZ, getLoader()); + h += i.getNoise(getSeed() + superSeed + hc, sampleX, sampleZ, data, engine); } } } @@ -262,15 +261,15 @@ public class IrisGenerator extends IrisRegistrant { v = 0; } - v = hasCliffs() ? cliff(rx, rz, v, superSeed + 294596 + hc) : v; + v = hasCliffs() ? cliff(rx, rz, v, superSeed + 294596 + hc, data, engine) : v; v = hasCellCracks() ? cell(rx, rz, v, superSeed + 48622 + hc) : v; return v; } - private double cachedSurfaceHeight(double x, double z, long superSeed, SurfaceCacheStripe[] stripes) { + private double cachedSurfaceHeight(double x, double z, long superSeed, SurfaceCacheStripe[] stripes, IrisData data, Engine engine) { if (x != (int) x || z != (int) z) { - return sampleHeight(x, z, superSeed); + return sampleHeight(x, z, superSeed, data, engine); } long key = ((long) (int) x << 32) | ((int) z & 0xffffffffL); long mixed = key ^ (key >>> 33); @@ -282,7 +281,7 @@ public class IrisGenerator extends IrisRegistrant { if (cached != null) { return cached.height(); } - double height = sampleHeight(x, z, superSeed); + double height = sampleHeight(x, z, superSeed, data, engine); stripe.put(slot, new SurfaceSample(key, superSeed, height)); return height; } @@ -321,15 +320,25 @@ public class IrisGenerator extends IrisRegistrant { } public double getCliffHeight(double rx, double rz, double superSeed) { + IrisData data = getLoader(); + return getCliffHeight(rx, rz, superSeed, data, data == null ? null : data.getEngine()); + } + + private double getCliffHeight(double rx, double rz, double superSeed, IrisData data, Engine engine) { int hc = (int) ((cliffHeightMin * 10) + 10 + cliffHeightMax * getSeed() + offsetX + offsetZ); double sampleX = (rx + offsetX) / zoom; double sampleZ = (rz + offsetZ) / zoom; - double h = cliffHeightGenerator.getNoise((long) (getSeed() + superSeed + hc), sampleX, sampleZ, getLoader()); + double h = cliffHeightGenerator.getNoise((long) (getSeed() + superSeed + hc), sampleX, sampleZ, data, engine); return IrisInterpolation.lerp(cliffHeightMin, cliffHeightMax, h); } public double cliff(double rx, double rz, double v, double superSeed) { - double cliffHeight = getCliffHeight(rx, rz, superSeed - 34857); + IrisData data = getLoader(); + return cliff(rx, rz, v, superSeed, data, data == null ? null : data.getEngine()); + } + + private double cliff(double rx, double rz, double v, double superSeed, IrisData data, Engine engine) { + double cliffHeight = getCliffHeight(rx, rz, superSeed - 34857, data, engine); return (Math.round((v * 255D) / cliffHeight) * cliffHeight) / 255D; } diff --git a/core/src/main/java/art/arcane/iris/generation/noise/IrisGeneratorStyle.java b/core/src/main/java/art/arcane/iris/generation/noise/IrisGeneratorStyle.java index 317924b3a..85619a3e8 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/IrisGeneratorStyle.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/IrisGeneratorStyle.java @@ -110,7 +110,11 @@ public class IrisGeneratorStyle { } public CNG createForLayer(RNG rng, IrisData data, int octaveMultiplier) { - return createNoCache(rng, data, Math.clamp(octaveMultiplier, 1, 16), 0, false, resolveEngine(data)); + return createForLayer(rng, data, octaveMultiplier, resolveEngine(data)); + } + + CNG createForLayer(RNG rng, IrisData data, int octaveMultiplier, Engine engine) { + return createNoCache(rng, data, Math.clamp(octaveMultiplier, 1, 16), 0, false, engine); } public CNG createForPrebake(RNG rng, IrisData data, int fallbackCacheSize) { diff --git a/core/src/main/java/art/arcane/iris/generation/noise/IrisNoiseGenerator.java b/core/src/main/java/art/arcane/iris/generation/noise/IrisNoiseGenerator.java index 984449ac4..50dcc3a51 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/IrisNoiseGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/IrisNoiseGenerator.java @@ -102,8 +102,7 @@ public class IrisNoiseGenerator { this.enabled = enabled; } - protected CNG getGenerator(long superSeed, IrisData data) { - Engine engine = data == null ? null : data.getEngine(); + CNG getGenerator(long superSeed, IrisData data, Engine engine) { long generatorSeed = superSeed + GENERATOR_SEED_SALT - seed; CachedGenerator recent = recentGenerator.get(); if (recent != null && recent.key.matches(data, engine, generatorSeed)) { @@ -112,7 +111,7 @@ public class IrisNoiseGenerator { GeneratorKey key = new GeneratorKey(data, engine, generatorSeed); CNG generator = generators.computeIfAbsent(key, - ignored -> style.createForLayer(new RNG(generatorSeed), data, octaves)); + ignored -> style.createForLayer(new RNG(generatorSeed), data, octaves, engine)); if (generator != null) { recentGenerator.set(new CachedGenerator(key, generator)); } @@ -128,6 +127,14 @@ public class IrisNoiseGenerator { return offsetY; } + return getNoise(superSeed, xv, zv, data, data == null ? null : data.getEngine()); + } + + double getNoise(long superSeed, double xv, double zv, IrisData data, Engine engine) { + if (!enabled) { + return offsetY; + } + double x = xv; double z = zv; int g = 33; @@ -135,13 +142,13 @@ public class IrisNoiseGenerator { for (IrisNoiseGenerator i : fracture) { if (i.isEnabled()) { double fractureOffset = i.getOpacity() / 2D; - x += i.getNoise(superSeed + seed + g, xv, zv, data) - fractureOffset; - z += i.getNoise(superSeed + seed + g, zv, xv, data) - fractureOffset; + x += i.getNoise(superSeed + seed + g, xv, zv, data, engine) - fractureOffset; + z += i.getNoise(superSeed + seed + g, zv, xv, data, engine) - fractureOffset; } g += 819; } - CNG cng = getGenerator(superSeed, data); + CNG cng = getGenerator(superSeed, data, engine); double sampleX = (x / zoom) + offsetX; double sampleZ = (z / zoom) + offsetZ; double n = cng.noiseFast2D(sampleX, sampleZ) * opacity; diff --git a/core/src/main/java/art/arcane/iris/localization/DesktopUiMessages.java b/core/src/main/java/art/arcane/iris/localization/DesktopUiMessages.java index 3c045cd5a..a207c9b4e 100644 --- a/core/src/main/java/art/arcane/iris/localization/DesktopUiMessages.java +++ b/core/src/main/java/art/arcane/iris/localization/DesktopUiMessages.java @@ -111,6 +111,8 @@ public final class DesktopUiMessages { public static final TextKey PREGEN_CACHED = TextKey.of("iris.desktop.pregen.cached", "{method} · cached"); public static final TextKey PREGEN_FAILED = TextKey.of("iris.desktop.pregen.failed", "{count} failed · see server log"); public static final TextKey PREGEN_MAP = TextKey.of("iris.desktop.pregen.map", "Generation map"); + public static final TextKey PREGEN_CHUNK_COORDINATES = TextKey.of("iris.desktop.pregen.chunk_coordinates", "Chunk X {x} · Z {z}"); + public static final TextKey PREGEN_WINDOW_HINT = TextKey.of("iris.desktop.pregen.window_hint", "Closing this window keeps generation running."); public static final TextKey PREGEN_BOUNDS = TextKey.of("iris.desktop.pregen.bounds", "Chunks X {minX} to {maxX} · Z {minZ} to {maxZ}"); public static final TextKey PREGEN_WAITING = TextKey.of("iris.desktop.pregen.waiting", "Waiting"); public static final TextKey PREGEN_READY = TextKey.of("iris.desktop.pregen.ready", "Ready"); @@ -212,6 +214,7 @@ public final class DesktopUiMessages { PREGEN_SIXTY, PREGEN_RATE, PREGEN_ETA, PREGEN_ELAPSED, PREGEN_MEMORY_LABEL, PREGEN_MEMORY_USAGE, PREGEN_PRESSURE, PREGEN_PRESSURE_VALUE, PREGEN_METHOD, PREGEN_CACHED, PREGEN_FAILED, PREGEN_MAP, + PREGEN_CHUNK_COORDINATES, PREGEN_WINDOW_HINT, PREGEN_BOUNDS, PREGEN_WAITING, PREGEN_READY, PREGEN_EXISTING, PREGEN_NETWORK, PREGEN_TERRAIN_HINT, IMAGEMAP_TITLE, IMAGEMAP_PRESET, IMAGEMAP_LOAD, IMAGEMAP_IMPORT_PNG, diff --git a/core/src/main/java/art/arcane/iris/studio/view/PregenMapPanel.java b/core/src/main/java/art/arcane/iris/studio/view/PregenMapPanel.java index 6d68be8fc..4f0c974af 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/PregenMapPanel.java +++ b/core/src/main/java/art/arcane/iris/studio/view/PregenMapPanel.java @@ -1,90 +1,65 @@ package art.arcane.iris.studio.view; +import art.arcane.iris.localization.DesktopUiMessages; +import art.arcane.iris.localization.IrisLanguage; +import art.arcane.volmlib.util.localization.MessageArgument; + import javax.swing.JPanel; +import javax.swing.ToolTipManager; import java.awt.Color; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Rectangle; import java.awt.RenderingHints; +import java.awt.event.MouseEvent; import java.awt.image.BufferedImage; import java.awt.image.DataBufferInt; -import java.util.Arrays; -import java.util.BitSet; -import java.util.concurrent.locks.ReentrantLock; final class PregenMapPanel extends JPanel { - private static final int MAX_RASTER_SIZE = 1024; - static final Color WAITING = new Color(29, 35, 46); - private final PregenRenderSnapshot.Bounds bounds; + private final PregenMapState state; + private final PregenMapState.View view; private final BufferedImage image; - private final int[] imagePixels; - private final int[] pendingPixels; - private final BitSet dirtyPixels; - private final ReentrantLock lock = new ReentrantLock(); - private volatile boolean pendingChanges; private volatile boolean disposed; - PregenMapPanel(PregenRenderSnapshot.Bounds bounds) { - this.bounds = bounds; - int width = (int) Math.min(MAX_RASTER_SIZE, bounds.width()); - int height = (int) Math.min(MAX_RASTER_SIZE, bounds.height()); - image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); - imagePixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); - pendingPixels = new int[imagePixels.length]; - dirtyPixels = new BitSet(imagePixels.length); - Arrays.fill(imagePixels, WAITING.getRGB()); - Arrays.fill(pendingPixels, WAITING.getRGB()); + PregenMapPanel(PregenMapState state) { + this.state = state; + bounds = state.bounds(); + image = new BufferedImage(state.width(), state.height(), BufferedImage.TYPE_INT_RGB); + view = state.attach(((DataBufferInt) image.getRaster().getDataBuffer()).getData()); setBackground(PregenRenderer.BACKGROUND); + ToolTipManager.sharedInstance().registerComponent(this); } void submit(int chunkX, int chunkZ, Color color) { - if (disposed || !bounds.contains(chunkX, chunkZ)) { - return; - } - int x = (int) (((long) chunkX - bounds.minX()) * image.getWidth() / bounds.width()); - int z = (int) (((long) chunkZ - bounds.minZ()) * image.getHeight() / bounds.height()); - int index = z * image.getWidth() + x; - int rgb = color.getRGB(); - lock.lock(); - try { - if (disposed || pendingPixels[index] == rgb) { - return; - } - pendingPixels[index] = rgb; - dirtyPixels.set(index); - pendingChanges = true; - } finally { - lock.unlock(); + if (!disposed) { + state.submit(chunkX, chunkZ, color); } } boolean flush() { - if (!pendingChanges || disposed) { - return false; - } - lock.lock(); - try { - for (int index = dirtyPixels.nextSetBit(0); index >= 0; index = dirtyPixels.nextSetBit(index + 1)) { - imagePixels[index] = pendingPixels[index]; - } - dirtyPixels.clear(); - pendingChanges = false; - } finally { - lock.unlock(); - } - return true; + return !disposed && view.flush(); } void disposeMap() { - lock.lock(); - try { - disposed = true; - dirtyPixels.clear(); - pendingChanges = false; - } finally { - lock.unlock(); + disposed = true; + ToolTipManager.sharedInstance().unregisterComponent(this); + view.close(); + } + + @Override + public String getToolTipText(MouseEvent event) { + if (disposed) { + return null; } + Rectangle area = fit(bounds, getWidth(), getHeight()); + if (!area.contains(event.getX(), event.getY())) { + return null; + } + int chunkX = (int) (bounds.minX() + (long) (event.getX() - area.x) * bounds.width() / area.width); + int chunkZ = (int) (bounds.minZ() + (long) (event.getY() - area.y) * bounds.height() / area.height); + return IrisLanguage.plain(DesktopUiMessages.PREGEN_CHUNK_COORDINATES, + MessageArgument.trusted("x", chunkX), MessageArgument.trusted("z", chunkZ)); } @Override diff --git a/core/src/main/java/art/arcane/iris/studio/view/PregenMapState.java b/core/src/main/java/art/arcane/iris/studio/view/PregenMapState.java new file mode 100644 index 000000000..4f5c424c6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/view/PregenMapState.java @@ -0,0 +1,123 @@ +package art.arcane.iris.studio.view; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.BitSet; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantLock; + +public final class PregenMapState { + private static final int MAX_RASTER_SIZE = 1024; + static final Color WAITING = new Color(29, 35, 46); + + private final PregenRenderSnapshot.Bounds bounds; + private final int width; + private final int height; + private final int[] pixels; + private final List views = new ArrayList<>(1); + private final ReentrantLock lock = new ReentrantLock(); + + PregenMapState(PregenRenderSnapshot.Bounds bounds) { + this.bounds = Objects.requireNonNull(bounds); + width = (int) Math.min(MAX_RASTER_SIZE, bounds.width()); + height = (int) Math.min(MAX_RASTER_SIZE, bounds.height()); + pixels = new int[width * height]; + Arrays.fill(pixels, WAITING.getRGB()); + } + + PregenRenderSnapshot.Bounds bounds() { + return bounds; + } + + int width() { + return width; + } + + int height() { + return height; + } + + void submit(int chunkX, int chunkZ, Color color) { + if (!bounds.contains(chunkX, chunkZ)) { + return; + } + int x = (int) (((long) chunkX - bounds.minX()) * width / bounds.width()); + int z = (int) (((long) chunkZ - bounds.minZ()) * height / bounds.height()); + int index = z * width + x; + int rgb = color.getRGB(); + lock.lock(); + try { + if (pixels[index] == rgb) { + return; + } + pixels[index] = rgb; + for (int viewIndex = 0; viewIndex < views.size(); viewIndex++) { + View view = views.get(viewIndex); + view.dirty.set(index); + view.pending = true; + } + } finally { + lock.unlock(); + } + } + + View attach(int[] target) { + if (target.length != pixels.length) { + throw new IllegalArgumentException("Map image must match the retained raster dimensions"); + } + View view = new View(target); + lock.lock(); + try { + System.arraycopy(pixels, 0, target, 0, pixels.length); + views.add(view); + } finally { + lock.unlock(); + } + return view; + } + + final class View { + private final int[] target; + private final BitSet dirty = new BitSet(pixels.length); + private volatile boolean pending; + private boolean disposed; + + private View(int[] target) { + this.target = target; + } + + boolean flush() { + if (!pending) { + return false; + } + lock.lock(); + try { + if (disposed || !pending) { + return false; + } + for (int index = dirty.nextSetBit(0); index >= 0; index = dirty.nextSetBit(index + 1)) { + target[index] = pixels[index]; + } + dirty.clear(); + pending = false; + return true; + } finally { + lock.unlock(); + } + } + + void close() { + lock.lock(); + try { + disposed = true; + pending = false; + dirty.clear(); + views.remove(this); + } finally { + lock.unlock(); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/view/PregenRenderSource.java b/core/src/main/java/art/arcane/iris/studio/view/PregenRenderSource.java index 2ef15ffcc..b10f152e5 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/PregenRenderSource.java +++ b/core/src/main/java/art/arcane/iris/studio/view/PregenRenderSource.java @@ -20,4 +20,6 @@ package art.arcane.iris.studio.view; public interface PregenRenderSource { PregenRenderSnapshot renderSnapshot(); + + PregenMapState renderMapState(); } diff --git a/core/src/main/java/art/arcane/iris/studio/view/PregenRenderer.java b/core/src/main/java/art/arcane/iris/studio/view/PregenRenderer.java index b3701e83d..3b076c895 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/PregenRenderer.java +++ b/core/src/main/java/art/arcane/iris/studio/view/PregenRenderer.java @@ -35,16 +35,22 @@ import javax.swing.JFrame; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JProgressBar; +import javax.swing.JScrollPane; +import javax.swing.Scrollable; +import javax.swing.JTextArea; import javax.swing.KeyStroke; import javax.swing.SwingUtilities; import javax.swing.Timer; import javax.swing.plaf.basic.BasicButtonUI; import javax.swing.plaf.basic.BasicGraphicsUtils; import javax.swing.plaf.basic.BasicProgressBarUI; +import javax.swing.plaf.basic.BasicScrollBarUI; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Dimension; -import java.awt.FlowLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; import java.awt.Font; import java.awt.Frame; import java.awt.GridLayout; @@ -85,7 +91,7 @@ public final class PregenRenderer extends JPanel { private final JLabel worldLabel = label("", new Font(Font.SANS_SERIF, Font.BOLD, 21), TEXT); private final JLabel phaseLabel = label("", BODY_FONT, MUTED); private final JLabel countLabel = label("", BODY_FONT, TEXT); - private final JLabel percentLabel = label("", BODY_FONT, MUTED); + private final JLabel percentLabel = label("", new Font(Font.SANS_SERIF, Font.BOLD, 24), TEXT); private final JLabel currentValue = label("", VALUE_FONT, TEXT); private final JLabel overallValue = label("", VALUE_FONT, TEXT); private final JLabel thirtyValue = label("", VALUE_FONT, TEXT); @@ -95,7 +101,8 @@ public final class PregenRenderer extends JPanel { private final JLabel memoryValue = label("", VALUE_FONT, TEXT); private final JLabel pressureValue = label("", VALUE_FONT, TEXT); private final JLabel methodLabel = label("", LABEL_FONT, MUTED); - private final JLabel failureLabel = label("", LABEL_FONT, ERROR); + private final JTextArea failureLabel = wrappedText("", BODY_FONT, ERROR, SURFACE); + private String controlFailure; private final JButton pauseButton = new JButton(); private final JProgressBar progressBar = new JProgressBar(0, 10_000); private volatile boolean renderingEnabled; @@ -106,14 +113,18 @@ public final class PregenRenderer extends JPanel { private PregenRenderer(PregenRenderSource source, Runnable onPause) { this.source = Objects.requireNonNull(source, "Generation view source"); this.onPause = onPause; + failureLabel.setFocusable(true); PregenRenderSnapshot initial = source.renderSnapshot(); - map = new PregenMapPanel(initial.bounds()); + map = new PregenMapPanel(source.renderMapState()); setLayout(new BorderLayout(0, 18)); setBackground(BACKGROUND); setBorder(BorderFactory.createEmptyBorder(20, 24, 18, 24)); add(header(), BorderLayout.NORTH); - add(mapArea(initial.bounds()), BorderLayout.CENTER); - add(statusArea(), BorderLayout.SOUTH); + JPanel content = panel(new BorderLayout(18, 0), BACKGROUND); + content.add(mapArea(initial.bounds()), BorderLayout.CENTER); + content.add(statusArea(), BorderLayout.EAST); + add(content, BorderLayout.CENTER); + add(wrappedText(text(DesktopUiMessages.PREGEN_WINDOW_HINT), LABEL_FONT, MUTED, BACKGROUND), BorderLayout.SOUTH); installPauseControl(); repaintTimer = new Timer(IrisSettings.get().getGui().isMaximumPregenGuiFPS() ? 4 : 250, event -> refresh()); @@ -130,8 +141,8 @@ public final class PregenRenderer extends JPanel { renderer.frame = frame; renderer.renderingEnabled = true; frame.setContentPane(renderer); - frame.setSize(1040, 900); - frame.setMinimumSize(new Dimension(760, 660)); + frame.setSize(1040, 760); + frame.setMinimumSize(new Dimension(760, 620)); frame.addWindowListener(new WindowAdapter() { @Override public void windowClosed(WindowEvent event) { @@ -144,10 +155,6 @@ public final class PregenRenderer extends JPanel { return renderer; } - public void submit(int x, int z, Color color) { - map.submit(x, z, color); - } - public boolean isVisibleFrame() { return renderingEnabled && !disposed; } @@ -199,36 +206,89 @@ public final class PregenRenderer extends JPanel { MessageArgument.trusted("maxX", Form.f(bounds.maxX())), MessageArgument.trusted("minZ", Form.f(bounds.minZ())), MessageArgument.trusted("maxZ", Form.f(bounds.maxZ()))), LABEL_FONT, MUTED); - area.add(coordinates, BorderLayout.SOUTH); + JPanel mapFooter = panel(new BorderLayout(0, 10), BACKGROUND); + mapFooter.add(coordinates, BorderLayout.NORTH); + mapFooter.add(legend(), BorderLayout.CENTER); + area.add(mapFooter, BorderLayout.SOUTH); return area; } - private JPanel statusArea() { - JPanel metrics = panel(new GridLayout(2, 4, 24, 20), SURFACE); - metrics.add(metric(DesktopUiMessages.PREGEN_CURRENT, currentValue)); - metrics.add(metric(DesktopUiMessages.PREGEN_OVERALL, overallValue)); - metrics.add(metric(DesktopUiMessages.PREGEN_THIRTY, thirtyValue)); - metrics.add(metric(DesktopUiMessages.PREGEN_SIXTY, sixtyValue)); - metrics.add(metric(DesktopUiMessages.PREGEN_ETA, etaValue)); - metrics.add(metric(DesktopUiMessages.PREGEN_ELAPSED, elapsedValue)); - metrics.add(metric(DesktopUiMessages.PREGEN_MEMORY_LABEL, memoryValue)); - metrics.add(metric(DesktopUiMessages.PREGEN_PRESSURE, pressureValue)); - JPanel method = panel(new BorderLayout(16, 0), SURFACE); - method.add(methodLabel, BorderLayout.CENTER); - method.add(failureLabel, BorderLayout.EAST); - JPanel status = panel(new BorderLayout(0, 18), SURFACE); - status.setBorder(BorderFactory.createEmptyBorder(18, 18, 16, 18)); - status.add(metrics, BorderLayout.CENTER); - status.add(method, BorderLayout.SOUTH); - JPanel footer = panel(new BorderLayout(0, 12), BACKGROUND); - footer.add(legend(), BorderLayout.NORTH); - footer.add(status, BorderLayout.CENTER); - return footer; + private JScrollPane statusArea() { + JPanel status = new StatusPanel(); + status.setBackground(SURFACE); + status.setBorder(BorderFactory.createEmptyBorder(18, 16, 16, 16)); + GridBagConstraints constraints = new GridBagConstraints(); + constraints.gridx = 0; + constraints.gridy = 0; + constraints.weightx = 1; + constraints.fill = GridBagConstraints.HORIZONTAL; + constraints.anchor = GridBagConstraints.NORTHWEST; + constraints.insets = new Insets(0, 0, 18, 0); + status.add(failureLabel, constraints); + constraints.gridy++; + currentValue.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 25)); + etaValue.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 23)); + status.add(metric(DesktopUiMessages.PREGEN_CURRENT, currentValue), constraints); + constraints.gridy++; + status.add(metric(DesktopUiMessages.PREGEN_ETA, etaValue), constraints); + constraints.gridy++; + constraints.insets = new Insets(0, 0, 10, 0); + status.add(metricRow(DesktopUiMessages.PREGEN_OVERALL, overallValue), constraints); + constraints.gridy++; + status.add(metricRow(DesktopUiMessages.PREGEN_THIRTY, thirtyValue), constraints); + constraints.gridy++; + status.add(metricRow(DesktopUiMessages.PREGEN_SIXTY, sixtyValue), constraints); + constraints.gridy++; + status.add(metricRow(DesktopUiMessages.PREGEN_ELAPSED, elapsedValue), constraints); + constraints.gridy++; + status.add(metricRow(DesktopUiMessages.PREGEN_MEMORY_LABEL, memoryValue), constraints); + constraints.gridy++; + status.add(metricRow(DesktopUiMessages.PREGEN_PRESSURE, pressureValue), constraints); + constraints.gridy++; + status.add(methodLabel, constraints); + constraints.gridy++; + constraints.weighty = 1; + status.add(panel(new BorderLayout(), SURFACE), constraints); + JScrollPane scroll = new JScrollPane(status); + scroll.setBorder(BorderFactory.createLineBorder(BORDER)); + scroll.getViewport().setBackground(SURFACE); + scroll.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); + scroll.setPreferredSize(new Dimension(276, 1)); + scroll.setMinimumSize(new Dimension(260, 0)); + scroll.getVerticalScrollBar().setUnitIncrement(16); + scroll.getVerticalScrollBar().setUI(new BasicScrollBarUI() { + @Override + protected void configureScrollBarColors() { + thumbColor = BORDER; + thumbDarkShadowColor = BORDER; + thumbHighlightColor = BORDER; + thumbLightShadowColor = BORDER; + trackColor = FIELD; + } + + @Override + protected JButton createDecreaseButton(int orientation) { + return scrollEnd(); + } + + @Override + protected JButton createIncreaseButton(int orientation) { + return scrollEnd(); + } + + private JButton scrollEnd() { + JButton button = new JButton(); + button.setPreferredSize(new Dimension(0, 0)); + button.setFocusable(false); + return button; + } + }); + return scroll; } private JPanel legend() { - JPanel legend = panel(new FlowLayout(FlowLayout.LEFT, 0, 0), BACKGROUND); - legend.add(legendItem(DesktopUiMessages.PREGEN_WAITING, PregenMapPanel.WAITING)); + JPanel legend = panel(new GridLayout(0, 2, 12, 7), BACKGROUND); + legend.add(legendItem(DesktopUiMessages.PREGEN_WAITING, PregenMapState.WAITING)); legend.add(legendItem(DesktopUiMessages.PREGEN_GENERATING, GENERATING)); legend.add(legendItem(DesktopUiMessages.PREGEN_READY, GENERATED)); legend.add(legendItem(DesktopUiMessages.PREGEN_EXISTING, EXISTS)); @@ -238,24 +298,32 @@ public final class PregenRenderer extends JPanel { } private JPanel legendItem(TextKey key, Color color) { - JPanel item = panel(new FlowLayout(FlowLayout.LEFT, 6, 0), BACKGROUND); - JPanel swatch = new JPanel(); - swatch.setBackground(color); + JPanel item = panel(new BorderLayout(7, 0), BACKGROUND); + JPanel swatch = panel(new BorderLayout(), color); swatch.setPreferredSize(new Dimension(10, 10)); swatch.setBorder(BorderFactory.createLineBorder(BORDER)); - item.add(swatch); - item.add(label(text(key), LABEL_FONT, MUTED)); - item.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 13)); + JPanel marker = panel(new BorderLayout(), BACKGROUND); + marker.add(swatch, BorderLayout.NORTH); + item.add(marker, BorderLayout.WEST); + item.add(wrappedText(text(key), LABEL_FONT, MUTED, BACKGROUND), BorderLayout.CENTER); return item; } private JPanel metric(TextKey title, JLabel value) { JPanel metric = panel(new BorderLayout(0, 5), SURFACE); - metric.add(label(text(title), LABEL_FONT, MUTED), BorderLayout.NORTH); + metric.add(wrappedText(text(title), LABEL_FONT, MUTED, SURFACE), BorderLayout.NORTH); metric.add(value, BorderLayout.CENTER); return metric; } + private JPanel metricRow(TextKey title, JLabel value) { + JPanel row = panel(new BorderLayout(8, 0), SURFACE); + value.setFont(BODY_FONT); + row.add(wrappedText(text(title), LABEL_FONT, MUTED, SURFACE), BorderLayout.CENTER); + row.add(value, BorderLayout.EAST); + return row; + } + private void installPauseControl() { pauseButton.setUI(new BasicButtonUI() { @Override @@ -288,6 +356,14 @@ public final class PregenRenderer extends JPanel { } }); pauseButton.addActionListener(event -> togglePause()); + pauseButton.getInputMap(JComponent.WHEN_FOCUSED).put( + KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "activatePause"); + pauseButton.getActionMap().put("activatePause", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent event) { + pauseButton.doClick(); + } + }); getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_P, 0, true), "pause"); getActionMap().put("pause", new AbstractAction() { @Override @@ -309,10 +385,14 @@ public final class PregenRenderer extends JPanel { } try { onPause.run(); + controlFailure = null; + displayedSnapshot = null; refresh(); } catch (RuntimeException failure) { IrisLogging.reportError("Unable to change pregeneration pause state.", failure); - failureLabel.setText(text(DesktopUiMessages.PREGEN_CONTROL_FAILED)); + controlFailure = text(DesktopUiMessages.PREGEN_CONTROL_FAILED) + "\n" + + Objects.toString(failure.getMessage(), failure.getClass().getSimpleName()); + updateStatus(source.renderSnapshot()); } } @@ -335,6 +415,7 @@ public final class PregenRenderer extends JPanel { boolean initializing = snapshot.phase() == PregenRenderSnapshot.Phase.INITIALIZING; boolean paused = snapshot.phase() == PregenRenderSnapshot.Phase.PAUSED; worldLabel.setText(progress.worldName() == null ? text(DesktopUiMessages.PREGEN_TITLE) : progress.worldName()); + worldLabel.setToolTipText(worldLabel.getText()); phaseLabel.setText(text(switch (snapshot.phase()) { case INITIALIZING -> DesktopUiMessages.PREGEN_INITIALIZING; case GENERATING -> DesktopUiMessages.PREGEN_GENERATING; @@ -351,6 +432,8 @@ public final class PregenRenderer extends JPanel { percentLabel.setText(text(DesktopUiMessages.PREGEN_PERCENT, MessageArgument.trusted("percent", Form.pc(Math.max(0D, Math.min(1D, progress.percent() / 100D)), 1)))); progressBar.setValue((int) Math.max(0D, Math.min(10_000D, progress.percent() * 100D))); + progressBar.getAccessibleContext().setAccessibleName(countLabel.getText()); + map.getAccessibleContext().setAccessibleDescription(countLabel.getText() + ". " + phaseLabel.getText()); pauseButton.setText(text(paused ? DesktopUiMessages.PREGEN_RESUME : DesktopUiMessages.PREGEN_PAUSE)); pauseButton.setToolTipText(text(paused ? DesktopUiMessages.PREGEN_RESUME_HINT : DesktopUiMessages.PREGEN_PAUSE_HINT)); pauseButton.setEnabled(onPause != null && switch (snapshot.phase()) { @@ -361,7 +444,9 @@ public final class PregenRenderer extends JPanel { overallValue.setText(initializing ? pending() : rate(progress.overallChunksPerSecond())); thirtyValue.setText(initializing ? pending() : rate(progress.thirtySecondChunksPerSecond())); sixtyValue.setText(initializing ? pending() : rate(progress.sixtySecondChunksPerSecond())); - etaValue.setText(initializing || paused || progress.eta() < 0 + etaValue.setText((snapshot.phase() != PregenRenderSnapshot.Phase.GENERATING + && snapshot.phase() != PregenRenderSnapshot.Phase.SAVING + && snapshot.phase() != PregenRenderSnapshot.Phase.COMPLETED) || progress.eta() < 0 || (progress.eta() == 0 && progress.chunksRemaining() > 0) ? pending() : Form.duration(progress.eta(), 2)); elapsedValue.setText(initializing ? pending() : Form.duration(progress.elapsed(), 2)); @@ -375,12 +460,20 @@ public final class PregenRenderer extends JPanel { method = text(DesktopUiMessages.PREGEN_CACHED, MessageArgument.untrusted("method", method)); } methodLabel.setText(text(DesktopUiMessages.PREGEN_METHOD, MessageArgument.untrusted("method", method))); - failureLabel.setText(progress.failed() > 0 ? text(DesktopUiMessages.PREGEN_FAILED, - MessageArgument.trusted("count", Form.f(progress.failed()))) : ""); - failureLabel.setToolTipText(snapshot.failure()); + methodLabel.setToolTipText(methodLabel.getText()); + String failureSummary = progress.failed() > 0 ? text(DesktopUiMessages.PREGEN_FAILED, + MessageArgument.trusted("count", Form.f(progress.failed()))) : ""; if (snapshot.failure() != null) { - failureLabel.setText(text(DesktopUiMessages.PREGEN_ERROR_DETAILS)); + failureSummary += (failureSummary.isEmpty() ? "" : "\n") + + text(DesktopUiMessages.PREGEN_ERROR_DETAILS) + "\n" + snapshot.failure(); } + if (controlFailure != null) { + failureSummary += (failureSummary.isEmpty() ? "" : "\n") + controlFailure; + } + if (!failureSummary.equals(failureLabel.getText())) { + failureLabel.setText(failureSummary); + } + failureLabel.setVisible(!failureSummary.isEmpty()); } private void setRenderingEnabled(boolean enabled) { @@ -418,6 +511,19 @@ public final class PregenRenderer extends JPanel { return label; } + private static JTextArea wrappedText(String text, Font font, Color foreground, Color background) { + JTextArea area = new JTextArea(text); + area.setEditable(false); + area.setFocusable(false); + area.setLineWrap(true); + area.setWrapStyleWord(true); + area.setFont(font); + area.setForeground(foreground); + area.setBackground(background); + area.setBorder(BorderFactory.createEmptyBorder()); + return area; + } + private static String pending() { return text(DesktopUiMessages.PREGEN_METHOD_PENDING); } @@ -429,4 +535,35 @@ public final class PregenRenderer extends JPanel { private static String text(TextKey key, MessageArgument... arguments) { return IrisLanguage.plain(key, arguments); } + + private static final class StatusPanel extends JPanel implements Scrollable { + private StatusPanel() { + super(new GridBagLayout()); + } + + @Override + public Dimension getPreferredScrollableViewportSize() { + return getPreferredSize(); + } + + @Override + public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) { + return 16; + } + + @Override + public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) { + return Math.max(16, visible.height - 16); + } + + @Override + public boolean getScrollableTracksViewportWidth() { + return true; + } + + @Override + public boolean getScrollableTracksViewportHeight() { + return getParent() != null && getPreferredSize().height < getParent().getHeight(); + } + } } diff --git a/core/src/main/java/art/arcane/iris/studio/view/PregeneratorJob.java b/core/src/main/java/art/arcane/iris/studio/view/PregeneratorJob.java index c90ed45fd..a71b57941 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/PregeneratorJob.java +++ b/core/src/main/java/art/arcane/iris/studio/view/PregeneratorJob.java @@ -65,6 +65,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { private final List whenDone = new CopyOnWriteArrayList<>(); private final IrisPregenerator pregenerator; private final PregenRenderSnapshot.Bounds bounds; + private final PregenMapState mapState; private final Engine engine; private final ExecutorService service; private final Thread worker; @@ -98,6 +99,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { this.pregenerator = new IrisPregenerator(task, method, this); int[] chunkBounds = task.chunkBounds(); bounds = new PregenRenderSnapshot.Bounds(chunkBounds[0], chunkBounds[1], chunkBounds[2], chunkBounds[3]); + mapState = new PregenMapState(bounds); lastTotalChunks = task.chunkCount(); lastChunksRemaining = lastTotalChunks; publishView(PregenRenderSnapshot.Phase.INITIALIZING); @@ -371,23 +373,13 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { } public void drawRegion(int x, int z, Color color) { - PregenRenderer activeRenderer = renderer; - if (activeRenderer == null || closed) { - return; + if (!closed) { + task.iterateChunks(x, z, (chunkX, chunkZ) -> mapState.submit(chunkX, chunkZ, color)); } - task.iterateChunks(x, z, (chunkX, chunkZ) -> activeRenderer.submit(chunkX, chunkZ, color)); } public void draw(int x, int z, Color color) { - try { - PregenRenderer activeRenderer = renderer; - if (activeRenderer != null) { - activeRenderer.submit(x, z, color); - } - } catch (Throwable error) { - IrisLogging.reportError(error); - IrisLogging.error("Failed to draw pregen"); - } + mapState.submit(x, z, color); } public void stop() { @@ -588,6 +580,11 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { return renderSnapshot; } + @Override + public PregenMapState renderMapState() { + return mapState; + } + private synchronized void publishView(PregenRenderSnapshot.Phase phase) { if (failure != null) { phase = PregenRenderSnapshot.Phase.ERROR; @@ -603,12 +600,9 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { } private void drawChunkPreview(int x, int z, Color statusColor) { - PregenRenderer activeRenderer = renderer; - if (activeRenderer == null) { - return; - } draw(x, z, statusColor); - if (!activeRenderer.isVisibleFrame() || service.isShutdown()) { + PregenRenderer activeRenderer = renderer; + if (activeRenderer == null || !activeRenderer.isVisibleFrame() || service.isShutdown()) { return; } if (engine != null) { diff --git a/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java b/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java index 4cd56bad3..bdccfd5bb 100644 --- a/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java +++ b/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java @@ -20,6 +20,7 @@ public final class BoundaryColumnGeometry implements NativeBlockColumn { private final List palette; private final int[] runEnds; private final short[] paletteIndices; + private final int highestSolidOffset; public BoundaryColumnGeometry(int minimumY, List palette, int[] runEnds, short[] paletteIndices) { this.minimumY = minimumY; @@ -27,6 +28,7 @@ public final class BoundaryColumnGeometry implements NativeBlockColumn { this.runEnds = Objects.requireNonNull(runEnds, "Geometry run ends").clone(); this.paletteIndices = Objects.requireNonNull(paletteIndices, "Geometry palette indices").clone(); validate(); + highestSolidOffset = findHighestSolidOffset(); } public static BoundaryColumnGeometry empty() { @@ -112,20 +114,16 @@ public final class BoundaryColumnGeometry implements NativeBlockColumn { } public boolean hasSolidAbove(int worldY) { - long offset = (long) worldY - minimumY; - if (offset >= height() - 1L) { - return false; - } - int startRun = offset < 0 ? 0 : runAt((int) offset); - for (int run = startRun; run < runEnds.length; run++) { - if (runEnds[run] <= offset + 1L) { - continue; - } + return highestSolidOffset >= 0 && (long) worldY - minimumY < highestSolidOffset; + } + + private int findHighestSolidOffset() { + for (int run = runEnds.length - 1; run >= 0; run--) { if (palette.get(paletteIndices[run]).phase() == Phase.SOLID) { - return true; + return runEnds[run] - 1; } } - return false; + return -1; } public List voxels() { diff --git a/core/src/main/resources/languages/de_DE.toml b/core/src/main/resources/languages/de_DE.toml index cf9ca7168..55472401e 100644 --- a/core/src/main/resources/languages/de_DE.toml +++ b/core/src/main/resources/languages/de_DE.toml @@ -1869,6 +1869,8 @@ status = "{name} | X: {x} Z: {z} | Zoom: {zoom} | Wert: {value} | {fps} "category.other" = "Sonstige" [iris.desktop.pregen] +chunk_coordinates = "Chunk X {x} · Z {z}" +window_hint = "Beim Schließen dieses Fensters läuft die Generierung weiter." initializing = "Initialisierung..." title = "Vorgenerierungsansicht" method_pending = "Ausstehend" diff --git a/core/src/main/resources/languages/es_ES.toml b/core/src/main/resources/languages/es_ES.toml index 09341bda9..70b48a67d 100644 --- a/core/src/main/resources/languages/es_ES.toml +++ b/core/src/main/resources/languages/es_ES.toml @@ -1869,6 +1869,8 @@ status = "{name} | X: {x} Z: {z} | Zoom: {zoom} | Valor: {value} | {fps "category.other" = "Otros" [iris.desktop.pregen] +chunk_coordinates = "Chunk X {x} · Z {z}" +window_hint = "Cerrar esta ventana no detiene la generación." initializing = "Inicializando..." title = "Vista de pregen" method_pending = "Pendiente" diff --git a/core/src/main/resources/languages/fi_FI.toml b/core/src/main/resources/languages/fi_FI.toml index 399ec90d3..5616b83eb 100644 --- a/core/src/main/resources/languages/fi_FI.toml +++ b/core/src/main/resources/languages/fi_FI.toml @@ -1874,6 +1874,8 @@ status = "{name} | X: {x} Z: {z} Zoom: {zoom} Arvo: {value} | {fps} FPS" "category.other" = "Muut" [iris.desktop.pregen] +chunk_coordinates = "Chunk X {x} · Z {z}" +window_hint = "Generointi jatkuu, vaikka suljet tämän ikkunan." initializing = "Alustetaan..." title = "Pregen- näkymä" method_pending = "Odotetaan" diff --git a/core/src/main/resources/languages/fr_FR.toml b/core/src/main/resources/languages/fr_FR.toml index 3f35a01e7..20e4834fd 100644 --- a/core/src/main/resources/languages/fr_FR.toml +++ b/core/src/main/resources/languages/fr_FR.toml @@ -1869,6 +1869,8 @@ status = "{name} | X: {x} Z: {z} | Zoom : {zoom} | Valeur : {value} | { "category.other" = "Autres" [iris.desktop.pregen] +chunk_coordinates = "Chunk X {x} · Z {z}" +window_hint = "La génération continue après la fermeture de cette fenêtre." initializing = "Initialisation..." title = "Vue de pregen" method_pending = "En attente" diff --git a/core/src/main/resources/languages/he_IL.toml b/core/src/main/resources/languages/he_IL.toml index 4b0b85b79..92ce65eb7 100644 --- a/core/src/main/resources/languages/he_IL.toml +++ b/core/src/main/resources/languages/he_IL.toml @@ -1874,6 +1874,8 @@ status = "{name} | X: {x} Z: {z} זום: {zoom} ערך: {value} | {fps} "category.other" = "אחרים" [iris.desktop.pregen] +chunk_coordinates = "צ'אנק X {x} · Z {z}" +window_hint = "היצירה נמשכת גם לאחר סגירת החלון." initializing = "הדמיה..." title = "תצוגה מקדימה" method_pending = "עקבו" diff --git a/core/src/main/resources/languages/it_IT.toml b/core/src/main/resources/languages/it_IT.toml index 30b6cd439..898f5b296 100644 --- a/core/src/main/resources/languages/it_IT.toml +++ b/core/src/main/resources/languages/it_IT.toml @@ -1869,6 +1869,8 @@ status = "{name} | X: {x} Z: {z} | Zoom: {zoom} | Valore: {value} | {fps} "category.other" = "Altri" [iris.desktop.pregen] +chunk_coordinates = "Chunk X {x} · Z {z}" +window_hint = "La generazione continua dopo la chiusura di questa finestra." initializing = "Inizializzazione..." title = "Vista pregenerazione" method_pending = "In attesa" diff --git a/core/src/main/resources/languages/ja-JP.toml b/core/src/main/resources/languages/ja-JP.toml index 4c5015852..2f3d4fc21 100644 --- a/core/src/main/resources/languages/ja-JP.toml +++ b/core/src/main/resources/languages/ja-JP.toml @@ -1872,6 +1872,8 @@ status = "{name} | X: {x} Z: {z} | ズーム: {zoom} | 値: {value} | { "category.other" = "その他" [iris.desktop.pregen] +chunk_coordinates = "チャンク X {x} · Z {z}" +window_hint = "このウィンドウを閉じても生成は続行されます。" initializing = "初期化中..." title = "事前生成ビュー" method_pending = "保留中" diff --git a/core/src/main/resources/languages/ko_KR.toml b/core/src/main/resources/languages/ko_KR.toml index 93f39434e..967099f9d 100644 --- a/core/src/main/resources/languages/ko_KR.toml +++ b/core/src/main/resources/languages/ko_KR.toml @@ -1872,6 +1872,8 @@ status = "{name} | X: {x} Z: {z} | 줌: {zoom} | 가치: {value} | {fps} "category.other" = "기타" [iris.desktop.pregen] +chunk_coordinates = "청크 X {x} · Z {z}" +window_hint = "이 창을 닫아도 생성은 계속됩니다." initializing = "초기화..." title = "Pregen 보기" method_pending = "대기 중" diff --git a/core/src/main/resources/languages/lt_LT.toml b/core/src/main/resources/languages/lt_LT.toml index 1775faf79..9fdbf193e 100644 --- a/core/src/main/resources/languages/lt_LT.toml +++ b/core/src/main/resources/languages/lt_LT.toml @@ -1874,6 +1874,8 @@ status = "{name} | X: {x} Z: {z} 124; didinimas: {zoom} SmithKline 124; Ver "category.other" = "Kita" [iris.desktop.pregen] +chunk_coordinates = "Chunk X {x} · Z {z}" +window_hint = "Uždarius šį langą generavimas tęsiamas." initializing = "Inicijuojama..." title = "Comment" method_pending = "Laukiama" diff --git a/core/src/main/resources/languages/nl_NL.toml b/core/src/main/resources/languages/nl_NL.toml index 6818e6baa..3817f6169 100644 --- a/core/src/main/resources/languages/nl_NL.toml +++ b/core/src/main/resources/languages/nl_NL.toml @@ -1869,6 +1869,8 @@ status = "{name} | X: {x} Z: {z} Zoom: {zoom} Waarde: {value} | {fps} FP "category.other" = "Andere" [iris.desktop.pregen] +chunk_coordinates = "Chunk X {x} · Z {z}" +window_hint = "Het genereren gaat door wanneer je dit venster sluit." initializing = "Initialiseren..." title = "Beeld instellen" method_pending = "In afwachting" diff --git a/core/src/main/resources/languages/pl_PL.toml b/core/src/main/resources/languages/pl_PL.toml index c08cfd8d8..32922cf6e 100644 --- a/core/src/main/resources/languages/pl_PL.toml +++ b/core/src/main/resources/languages/pl_PL.toml @@ -1874,6 +1874,8 @@ status = "{name} | X: {x} Z: {z} | Zoom: {zoom} | Wartość: {value} | "category.other" = "Inne" [iris.desktop.pregen] +chunk_coordinates = "Chunk X {x} · Z {z}" +window_hint = "Generowanie trwa dalej po zamknięciu tego okna." initializing = "Inicjowanie..." title = "Widok z góry" method_pending = "Oczekiwanie" diff --git a/core/src/main/resources/languages/pt_PT.toml b/core/src/main/resources/languages/pt_PT.toml index 753610da6..d2a4a1335 100644 --- a/core/src/main/resources/languages/pt_PT.toml +++ b/core/src/main/resources/languages/pt_PT.toml @@ -1869,6 +1869,8 @@ status = "{name} | X: {x} Z: {z} □ Zoom: {zoom} Valor: {value} | {fps} "category.other" = "Outros" [iris.desktop.pregen] +chunk_coordinates = "Chunk X {x} · Z {z}" +window_hint = "A geração continua após fechar esta janela." initializing = "Inicializando..." title = "Visão Pregen" method_pending = "Pendente" diff --git a/core/src/main/resources/languages/ru_RU.toml b/core/src/main/resources/languages/ru_RU.toml index d5ac2aded..bb948faf5 100644 --- a/core/src/main/resources/languages/ru_RU.toml +++ b/core/src/main/resources/languages/ru_RU.toml @@ -1874,6 +1874,8 @@ status = "{name} | X: {x} Z: {z} Зум: {zoom} Величина: {value} "category.other" = "другой" [iris.desktop.pregen] +chunk_coordinates = "Чанк X {x} · Z {z}" +window_hint = "После закрытия этого окна генерация продолжится." initializing = "инициировать..." title = "Прегеновский взгляд" method_pending = "В ожидании" diff --git a/core/src/main/resources/languages/tr_TR.toml b/core/src/main/resources/languages/tr_TR.toml index 02163a561..f53c5c664 100644 --- a/core/src/main/resources/languages/tr_TR.toml +++ b/core/src/main/resources/languages/tr_TR.toml @@ -1874,6 +1874,8 @@ status = "{name} | X: {x} Z: {z} yakınlaştırma: {zoom} Değer: {value} "category.other" = "Diğer" [iris.desktop.pregen] +chunk_coordinates = "Chunk X {x} · Z {z}" +window_hint = "Bu pencere kapatılsa da oluşturma devam eder." initializing = "Başlangıç..." title = "Prejen View" method_pending = "Pending Ping" diff --git a/core/src/main/resources/languages/vi_VI.toml b/core/src/main/resources/languages/vi_VI.toml index 616ac7557..c8ace9fa5 100644 --- a/core/src/main/resources/languages/vi_VI.toml +++ b/core/src/main/resources/languages/vi_VI.toml @@ -1872,6 +1872,8 @@ status = "{name} | X: {x} Z: {z} Phóng to- thu nhỏ {zoom} Giá trị {va "category.other" = "Khác" [iris.desktop.pregen] +chunk_coordinates = "Chunk X {x} · Z {z}" +window_hint = "Quá trình tạo vẫn tiếp tục khi đóng cửa sổ này." initializing = "Đang sở khởi..." title = "Xem mẫu" method_pending = "Đang đợi" diff --git a/core/src/main/resources/languages/zh_CN.toml b/core/src/main/resources/languages/zh_CN.toml index ec1feb7bf..4156dd8d7 100644 --- a/core/src/main/resources/languages/zh_CN.toml +++ b/core/src/main/resources/languages/zh_CN.toml @@ -1872,6 +1872,8 @@ status = "{name} | X: {x} Z: {z} 缩放 : {zoom} 数值 : {value} | {fps} "category.other" = "其他人员" [iris.desktop.pregen] +chunk_coordinates = "区块 X {x} · Z {z}" +window_hint = "关闭此窗口后仍会继续生成。" initializing = "初始化..." title = "Pregen 视图" method_pending = "待决" diff --git a/core/src/main/resources/languages/zh_TW.toml b/core/src/main/resources/languages/zh_TW.toml index 400b91ce3..55cdb61cc 100644 --- a/core/src/main/resources/languages/zh_TW.toml +++ b/core/src/main/resources/languages/zh_TW.toml @@ -1872,6 +1872,8 @@ status = "{name} | X: {x} Z: {z} 縮放 : {zoom} 數值 : {value} | {fps} "category.other" = "其他人員" [iris.desktop.pregen] +chunk_coordinates = "區塊 X {x} · Z {z}" +window_hint = "關閉此視窗後仍會繼續生成。" initializing = "初始化..." title = "Pregen 檢視" method_pending = "待決" diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanHashTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanHashTest.java index 2eeb2a939..4fa7e6e8c 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanHashTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanHashTest.java @@ -18,7 +18,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class HydrologyPlanHashTest { - private static final String EXPECTED_PLAN_DIGEST = "e54461c339854842f7fc7b2e85bd700b57942a2c691f5a249d8e498729378296"; + private static final String EXPECTED_PLAN_DIGEST = "ad4e63b9081878d0eac05c2aeacc80afa18a7f2bf4e06c18490211e7ae91fe99"; private static final List TILES = List.of( new HydrologyTileKey(0, 0), diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalConnectivityTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalConnectivityTest.java index fa386814c..e4299561a 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalConnectivityTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalConnectivityTest.java @@ -2,9 +2,13 @@ package art.arcane.iris.generation.hydrology; import org.junit.Test; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Random; import java.util.OptionalLong; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -92,6 +96,125 @@ public final class HydrologyRegionalConnectivityTest { assertTrue(flatChannel().connected(course, bridged, SETTINGS)); } + @Test + public void separatedIntervalsCanConnectAroundAnAdjacentWaterfall() { + HydrologyRegionalConnectivity water = flatChannel(); + water.addFluidColumn(1, 0, 67, 70); + water.addFluidColumn(1, 1, 60, 70); + assertTrue(water.connected(course(), ocean(), SETTINGS)); + } + + @Test + public void anIsolatedUpperIntervalStillRejectsTheCourse() { + HydrologyRegionalConnectivity water = flatChannel(); + water.addFluidColumn(1, 0, 67, 70); + water.addFluidColumn(1, 1, 60, 63); + assertFalse(water.connected(course(), ocean(), SETTINGS)); + } + + @Test + public void aHigherReceivingIntervalCanJoinTheOceanAboveAnotherInterval() { + HydrologyRegionalConnectivity water = flatChannel(); + water.addFluidColumn(2, 0, 40, 45); + water.addFluidColumn(2, 1, 40, 63); + assertTrue(water.connected(course(), ocean(), SETTINGS)); + } + + @Test + public void intervalConnectivityMatchesVoxelFloodFillRegardlessOfInsertionOrder() { + Random random = new Random(438761L); + int connected = 0; + int disconnected = 0; + for (int trial = 0; trial < 250; trial++) { + ArrayList intervals = new ArrayList<>(); + boolean[][][] voxels = new boolean[3][3][13]; + for (int x = 0; x < 3; x++) { + for (int z = 0; z < 3; z++) { + intervals.add(new int[]{x, z, 60, 63}); + if (x == 2 && z == 0) { + continue; + } + if (random.nextBoolean()) { + int bed = 64 + random.nextInt(3); + intervals.add(new int[]{x, z, bed, bed + 1 + random.nextInt(3)}); + } + if (random.nextBoolean()) { + intervals.add(new int[]{x, z, 60, 68 + random.nextInt(3)}); + } + } + } + for (int[] interval : intervals) { + for (int y = interval[2] + 1; y <= interval[3]; y++) { + voxels[interval[0]][interval[1]][y - 58] = true; + } + } + boolean expected = voxelConnected(voxels); + if (expected) { + connected++; + } else { + disconnected++; + } + for (int order = 0; order < 2; order++) { + Collections.shuffle(intervals, random); + HydrologyRegionalConnectivity water = new HydrologyRegionalConnectivity(); + for (int[] interval : intervals) { + water.addFluidColumn(interval[0], interval[1], interval[2], interval[3]); + } + assertEquals("Trial " + trial + ", order " + order, expected, + water.connected(course(), ocean(), SETTINGS)); + } + } + assertTrue(connected > 0); + assertTrue(disconnected > 0); + } + + private static boolean voxelConnected(boolean[][][] voxels) { + int height = voxels[0][0].length; + int[] queue = new int[3 * 3 * height]; + boolean[] visited = new boolean[queue.length]; + int total = 0; + int first = -1; + for (int x = 0; x < 3; x++) { + for (int z = 0; z < 3; z++) { + for (int y = 0; y < height; y++) { + if (voxels[x][z][y]) { + total++; + first = (x * 3 + z) * height + y; + } + } + } + } + if (first < 0) { + return false; + } + int[][] offsets = {{1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, {0, 0, 1}, {0, 0, -1}}; + int read = 0; + int write = 1; + queue[0] = first; + visited[first] = true; + while (read < write) { + int current = queue[read++]; + int y = current % height; + int z = current / height % 3; + int x = current / height / 3; + for (int[] offset : offsets) { + int nextX = x + offset[0]; + int nextZ = z + offset[1]; + int nextY = y + offset[2]; + if (nextX < 0 || nextX >= 3 || nextZ < 0 || nextZ >= 3 || nextY < 0 || nextY >= height + || !voxels[nextX][nextZ][nextY]) { + continue; + } + int next = (nextX * 3 + nextZ) * height + nextY; + if (!visited[next]) { + visited[next] = true; + queue[write++] = next; + } + } + } + return write == total; + } + private static HydrologyRegionalConnectivity flatChannel() { HydrologyRegionalConnectivity water = new HydrologyRegionalConnectivity(); for (int x = 0; x < 3; x++) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalMorphologyTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalMorphologyTest.java index 28343d804..d33199d1d 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalMorphologyTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalMorphologyTest.java @@ -12,6 +12,23 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class HydrologyRegionalMorphologyTest { + @Test + public void regionalWidthRestoresAuthoredVariationWithoutChangingFixedRangesOrConfines() { + HydrologyGeometrySampler.Request request = new HydrologyGeometrySampler.Request( + HydrologyGeometrySampler.Field.SURFACE_WIDTH, "water", 0, 0, 0L, 4, 12); + int narrow = HydrologyRegionalPlanner.regionalWidth(request, 4, 0.5D, 1D); + int wide = HydrologyRegionalPlanner.regionalWidth(request, 12, 0.5D, 1D); + assertTrue(wide - narrow >= 5); + for (double flow : List.of(0D, 0.5D, 1D)) { + assertEquals(4, HydrologyRegionalPlanner.regionalWidth(request, 8, flow, 0D)); + int width = HydrologyRegionalPlanner.regionalWidth(request, 8, flow, 1D); + assertTrue(width >= 4 && width <= 12); + HydrologyGeometrySampler.Request fixed = new HydrologyGeometrySampler.Request( + HydrologyGeometrySampler.Field.SURFACE_WIDTH, "water", 0, 0, 0L, 8, 8); + assertEquals(8, HydrologyRegionalPlanner.regionalWidth(fixed, 8, flow, 1D)); + } + } + @Test public void flatOpenTerrainRetainsFullFreedomWithoutSamplingDuringQueries() { AtomicInteger calls = new AtomicInteger(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalWidthAdaptationTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalWidthAdaptationTest.java index 7137b65ca..69b9c0186 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalWidthAdaptationTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalWidthAdaptationTest.java @@ -33,7 +33,7 @@ public class HydrologyRegionalWidthAdaptationTest { double fraction = 0.25D + 0.75D * flow.fraction(point.x(), point.z()); double accumulated = 4D + 4D * fraction; int authored = 4 + Math.floorMod(point.x() / 256, 5); - int expected = (int) StrictMath.round(accumulated * 0.75D + authored * 0.25D); + int expected = (int) StrictMath.round(accumulated * 0.35D + authored * 0.65D); int actual = width(planner, context, point.x()); assertEquals(expected, actual); assertTrue(actual >= previous); @@ -104,7 +104,11 @@ public class HydrologyRegionalWidthAdaptationTest { .build(71L, 91L, "default", guide, SurfaceTerminal.SINKHOLE, 40, 512); assertTrue(built.accepted()); - assertEquals(10D, storedWidth(built.segments(), 512), 0D); + HydrologyTerrainSampler plainTerrain = (x, z) -> HydrologyTerrainSample.openLand(80, 0D, "land"); + SurfaceCourseResult plain = new SurfaceCourseBuilder(SETTINGS.surface(), plainTerrain, geometry, SETTINGS.seaLevel()) + .build(71L, 91L, "default", guide, SurfaceTerminal.SINKHOLE, 40, 512); + assertTrue(plain.accepted()); + assertEquals(storedWidth(plain.segments(), 512) * 1.25D, storedWidth(built.segments(), 512), 1.0E-9D); } @Test diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ChannelProfileBuilderTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ChannelProfileBuilderTest.java index ab8afcc54..d018752d7 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ChannelProfileBuilderTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ChannelProfileBuilderTest.java @@ -9,9 +9,11 @@ import art.arcane.iris.generation.hydrology.HydrologyTerrainSample; import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler; import org.junit.Test; +import java.util.Arrays; import java.util.List; import java.util.function.IntBinaryOperator; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -25,19 +27,22 @@ public class ChannelProfileBuilderTest { @Test public void headwaterOpensAsASpringPoolThatNarrowsToTheCruiseWidth() { SurfaceCenterline centerline = straight(400); - ChannelProfile profile = builder(1D, 1D).build(centerline, "water", false); + ChannelProfile profile = builder(1D, 1D).build(centerline, "water", false, 0L); assertEquals(400, profile.size()); - assertEquals(3D, profile.halfWidth()[200], 1.0E-9D); - assertEquals(3D, profile.halfWidth()[399], 1.0E-9D); + assertTrue(profile.halfWidth()[200] >= 2D && profile.halfWidth()[200] <= 4D); + assertTrue(profile.halfWidth()[399] >= 2D && profile.halfWidth()[399] <= 4D); assertEquals(3D, profile.depth()[200], 1.0E-9D); - assertEquals(3D * 2.5D, profile.halfWidth()[0], 1.0E-9D); + assertEquals(baseWidth(0) * 1.9D, profile.halfWidth()[0], 1.0E-9D); + assertEquals(baseWidth(6) * 2.5D, profile.halfWidth()[6], 1.0E-9D); assertEquals(4D, profile.depth()[0], 1.0E-9D); for (int station = 1; station <= 24; station++) { - assertTrue(profile.halfWidth()[station] <= profile.halfWidth()[station - 1]); + double previousRatio = profile.halfWidth()[station - 1] / baseWidth(station - 1); + double ratio = profile.halfWidth()[station] / baseWidth(station); + assertTrue(station <= 6 ? ratio >= previousRatio : ratio <= previousRatio); assertTrue(profile.depth()[station] <= profile.depth()[station - 1]); } - assertEquals(3D, profile.halfWidth()[24], 1.0E-9D); + assertEquals(baseWidth(24), profile.halfWidth()[24], 1.0E-9D); assertEquals(3D, profile.depth()[24], 1.0E-9D); } @@ -46,24 +51,24 @@ public class ChannelProfileBuilderTest { SurfaceCenterline centerline = straight(400); HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface(); HydrologyPlannerSettings.Inlet inlet = defaults.banks().inlet(); - ChannelProfile profile = builder(1D, 1D).build(centerline, "water", true); + ChannelProfile profile = builder(1D, 1D).build(centerline, "water", true, 0L); ChannelProfile plain = builder(defaults.banks().withInlet(HydrologyPlannerSettings.Inlet.none())) - .build(centerline, "water", true); + .build(centerline, "water", true, 0L); assertEquals(64, inlet.length()); int flareStart = 400 - inlet.length(); - assertEquals(3D * defaults.banks().mouthFlareRatio(), profile.halfWidth()[399], 1.0E-9D); + assertEquals(plain.halfWidth()[399] * defaults.banks().mouthFlareRatio(), profile.halfWidth()[399], 1.0E-9D); assertEquals(3D + inlet.depth(), profile.depth()[399], 1.0E-9D); - assertEquals(3D, profile.halfWidth()[flareStart], 1.0E-9D); + assertEquals(plain.halfWidth()[flareStart], profile.halfWidth()[flareStart], 1.0E-9D); assertEquals(3D, profile.depth()[flareStart], 1.0E-9D); - assertTrue(profile.halfWidth()[flareStart + 1] > 3D); + assertTrue(profile.halfWidth()[flareStart + 1] > plain.halfWidth()[flareStart + 1]); assertTrue(profile.depth()[flareStart + 1] > 3D); - assertEquals(3D, profile.halfWidth()[300], 1.0E-9D); + assertEquals(plain.halfWidth()[300], profile.halfWidth()[300], 1.0E-9D); for (int station = flareStart + 1; station < 400; station++) { - assertTrue(profile.halfWidth()[station] >= profile.halfWidth()[station - 1]); + assertTrue(profile.halfWidth()[station] / plain.halfWidth()[station] >= profile.halfWidth()[station - 1] / plain.halfWidth()[station - 1]); assertTrue(profile.depth()[station] >= profile.depth()[station - 1]); } - assertEquals(3D, plain.halfWidth()[399], 1.0E-9D); + assertEquals(baseWidth(399), plain.halfWidth()[399], 1.0E-9D); assertEquals(3D, plain.depth()[399], 1.0E-9D); } @@ -75,39 +80,41 @@ public class ChannelProfileBuilderTest { HydrologyTerrainSampler coast = (int x, int z) -> x >= 380 ? HydrologyTerrainSample.ocean(50, "ocean") : HydrologyTerrainSample.openLand(80, 0D, "land"); - ChannelProfile profile = new ChannelProfileBuilder(defaults, coast, CONSTANT_GEOMETRY).build(centerline, "water", true); + ChannelProfile profile = new ChannelProfileBuilder(defaults, coast, CONSTANT_GEOMETRY).build(centerline, "water", true, 0L); + HydrologyPlannerSettings.Surface plainSurface = withBanks(defaults, defaults.banks().withInlet(HydrologyPlannerSettings.Inlet.none())); + ChannelProfile plain = new ChannelProfileBuilder(plainSurface, coast, CONSTANT_GEOMETRY).build(centerline, "water", true, 0L); int flareStart = 380 - inlet.length(); - assertEquals(3D, profile.halfWidth()[flareStart], 1.0E-9D); - assertTrue(profile.halfWidth()[flareStart + 1] > 3D); - assertEquals(3D * defaults.banks().mouthFlareRatio(), profile.halfWidth()[379], 1.0E-9D); + assertEquals(plain.halfWidth()[flareStart], profile.halfWidth()[flareStart], 1.0E-9D); + assertTrue(profile.halfWidth()[flareStart + 1] > plain.halfWidth()[flareStart + 1]); + assertEquals(plain.halfWidth()[379] * defaults.banks().mouthFlareRatio(), profile.halfWidth()[379], 1.0E-9D); assertEquals(3D + inlet.depth(), profile.depth()[379], 1.0E-9D); - assertEquals(3D * defaults.banks().mouthFlareRatio(), profile.halfWidth()[399], 1.0E-9D); - assertEquals(3D, profile.halfWidth()[300], 1.0E-9D); + assertEquals(plain.halfWidth()[399] * defaults.banks().mouthFlareRatio(), profile.halfWidth()[399], 1.0E-9D); + assertEquals(plain.halfWidth()[300], profile.halfWidth()[300], 1.0E-9D); } @Test public void policyMultipliersScaleWidthDepthAndBank() { SurfaceCenterline centerline = straight(400); - ChannelProfile profile = builder(2D, 0.5D).build(centerline, "water", false); + ChannelProfile profile = builder(2D, 0.5D).build(centerline, "water", false, 0L); - assertEquals(6D, profile.halfWidth()[200], 1.0E-9D); + assertEquals(baseWidth(200) * 2D, profile.halfWidth()[200], 1.0E-9D); assertEquals(1.5D, profile.depth()[200], 1.0E-9D); assertEquals(1.25D, profile.bankMultiplier()[200], 1.0E-9D); - assertEquals(6D * 1.25D + 2D, profile.collar(200, 0.25D), 1.0E-9D); + assertEquals(baseWidth(200) * 2D * 1.25D + 2D, profile.collar(200, 0.25D), 1.0E-9D); } @Test public void groundFallingAwayAcrossThePoolShrinksTheSpringPool() { SurfaceCenterline centerline = straight(400); - ChannelProfile sloped = builder(1D, 1D, (int x, int z) -> 80 - Math.abs(z) / 2).build(centerline, "water", false); - ChannelProfile cliff = builder(1D, 1D, (int x, int z) -> 80 - Math.abs(z) * 8).build(centerline, "water", false); + ChannelProfile sloped = builder(1D, 1D, (int x, int z) -> 80 - Math.abs(z) / 2).build(centerline, "water", false, 0L); + ChannelProfile cliff = builder(1D, 1D, (int x, int z) -> 80 - Math.abs(z) * 8).build(centerline, "water", false, 0L); - assertTrue(sloped.halfWidth()[0] > 3D); - assertTrue(sloped.halfWidth()[0] < 3D * 2.5D); - assertEquals(3D, cliff.halfWidth()[0], 1.0E-9D); + assertTrue(sloped.halfWidth()[0] > baseWidth(0)); + assertTrue(sloped.halfWidth()[0] < baseWidth(0) * 2.5D); + assertEquals(baseWidth(0), cliff.halfWidth()[0], 1.0E-9D); assertEquals(4D, cliff.depth()[0], 1.0E-9D); - assertEquals(3D, sloped.halfWidth()[24], 1.0E-9D); + assertEquals(baseWidth(24), sloped.halfWidth()[24], 1.0E-9D); } @Test @@ -119,16 +126,17 @@ public class ChannelProfileBuilderTest { }; SurfaceCenterline centerline = straight(400); ChannelProfile sharp = builder(new HydrologyPlannerSettings.Channel(0, 0.6D, 1.4D, 1D), stepped) - .build(centerline, "water", false); + .build(centerline, "water", false, 0L); ChannelProfile smoothed = builder(HydrologyPlannerSettings.Channel.defaults(), stepped) - .build(centerline, "water", false); + .build(centerline, "water", false, 0L); assertEquals(2D, sharp.halfWidth()[199], 1.0E-9D); - assertEquals(4D, sharp.halfWidth()[200], 1.0E-9D); + assertTrue(sharp.halfWidth()[200] > 2D && sharp.halfWidth()[200] <= 4D); assertEquals(2D, sharp.halfWidth()[100], 1.0E-9D); - assertEquals(4D, sharp.halfWidth()[300], 1.0E-9D); + assertTrue(sharp.halfWidth()[300] > 2D && sharp.halfWidth()[300] <= 4D); assertTrue(smoothed.halfWidth()[199] > 2D); - assertTrue(smoothed.halfWidth()[200] < 4D); + assertTrue(smoothed.halfWidth()[200] < sharp.halfWidth()[200]); + assertEquals(ChannelProfileBuilder.smooth(sharp.halfWidth(), 16)[200], smoothed.halfWidth()[200], 1.0E-9D); assertEquals(2D, smoothed.halfWidth()[100], 1.0E-9D); } @@ -136,9 +144,9 @@ public class ChannelProfileBuilderTest { public void springExtraDepthDeepensTheSpringPool() { SurfaceCenterline centerline = straight(400); ChannelProfile flat = builder(new HydrologyPlannerSettings.Channel(16, 0.6D, 1.4D, 0D), CONSTANT_GEOMETRY) - .build(centerline, "water", false); + .build(centerline, "water", false, 0L); ChannelProfile deep = builder(new HydrologyPlannerSettings.Channel(16, 0.6D, 1.4D, 3D), CONSTANT_GEOMETRY) - .build(centerline, "water", false); + .build(centerline, "water", false, 0L); assertEquals(3D, flat.depth()[0], 1.0E-9D); assertEquals(3D, flat.depth()[200], 1.0E-9D); @@ -147,6 +155,91 @@ public class ChannelProfileBuilderTest { assertEquals(3D, deep.depth()[200], 1.0E-9D); } + private static double baseWidth(int station) { + HydrologyPlannerSettings.Banks defaults = HydrologyPlannerSettings.defaults().surface().banks(); + HydrologyPlannerSettings.Banks plain = new HydrologyPlannerSettings.Banks( + defaults.sink(), defaults.blendSlope(), defaults.minimumBlendWidth(), defaults.maximumBlendWidth(), + defaults.roughness(), defaults.roughnessWavelength(), defaults.cascadeRun(), defaults.waterfallMinimumDrop(), + defaults.mouthFlareRatio(), HydrologyPlannerSettings.Inlet.none(), 1D, defaults.springLength(), + defaults.exposeCutStrata(), defaults.erosion(), defaults.ponds(), defaults.channel(), defaults.flow()); + return builder(plain).build(straight(400), "water", false, 0L).halfWidth()[station]; + } + + private static HydrologyPlannerSettings.Surface withBanks(HydrologyPlannerSettings.Surface surface, + HydrologyPlannerSettings.Banks banks) { + return new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), + surface.maximumWidth(), surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), + surface.shoreWidth(), banks); + } + + @Test + public void reachVariationIsBoundedRepeatableAndSharedAcrossCourseWindows() { + SurfaceCenterline complete = straight(512); + ChannelProfile first = builder(1D, 1D).build(complete, "water", false, 71L); + ChannelProfile repeated = builder(1D, 1D).build(complete, "water", false, 71L); + ChannelProfile otherSeed = builder(1D, 1D).build(complete, "water", false, 72L); + assertArrayEquals(first.halfWidth(), repeated.halfWidth(), 0D); + assertTrue(!Arrays.equals(first.halfWidth(), otherSeed.halfWidth())); + SurfaceCenterline window = SurfaceCenterline.densify(List.of( + new HydrologyPoint(128, 80, 0), new HydrologyPoint(511, 80, 0))); + ChannelProfile shifted = builder(1D, 1D).build(window, "water", false, 71L); + double minimum = Double.POSITIVE_INFINITY; + double maximum = 0D; + for (int station = 192; station < 352; station++) { + assertEquals(first.halfWidth()[station], shifted.halfWidth()[station - 128], 0D); + double width = first.halfWidth()[station] * 2D; + assertTrue(width >= 4D && width <= 8D); + minimum = Math.min(minimum, width); + maximum = Math.max(maximum, width); + } + assertTrue(maximum - minimum > 1D); + } + + @Test + public void fixedWidthSpringReachesItsConfiguredPeakEarlyAndPreservesDepth() { + HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface(); + HydrologyPlannerSettings.Surface fixed = new HydrologyPlannerSettings.Surface(defaults.enabled(), defaults.sources(), + 6, 6, defaults.minimumDepth(), defaults.maximumDepth(), defaults.maximumIncision(), defaults.shoreWidth(), defaults.banks()); + HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(80, 0D, "land"); + ChannelProfile profile = new ChannelProfileBuilder(fixed, terrain, CONSTANT_GEOMETRY) + .build(straight(400), "water", false, 71L); + assertEquals(3D * 1.9D, profile.halfWidth()[0], 0D); + assertEquals(3D * defaults.banks().springWidthRatio(), profile.halfWidth()[6], 0D); + assertEquals(3D, profile.halfWidth()[24], 0D); + assertEquals(4D, profile.depth()[0], 0D); + assertEquals(3D, profile.depth()[24], 0D); + for (int station = 0; station < 24; station++) { + assertTrue(profile.halfWidth()[station] <= 3D * defaults.banks().springWidthRatio()); + } + HydrologyPlannerSettings.Banks banks = defaults.banks(); + HydrologyPlannerSettings.Banks noSpringWidth = new HydrologyPlannerSettings.Banks(banks.sink(), banks.blendSlope(), + banks.minimumBlendWidth(), banks.maximumBlendWidth(), banks.roughness(), banks.roughnessWavelength(), + banks.cascadeRun(), banks.waterfallMinimumDrop(), banks.mouthFlareRatio(), banks.inlet(), 1D, banks.springLength(), + banks.exposeCutStrata(), banks.erosion(), banks.ponds(), banks.channel(), banks.flow()); + ChannelProfile narrow = new ChannelProfileBuilder(withBanks(fixed, noSpringWidth), terrain, CONSTANT_GEOMETRY) + .build(straight(400), "water", false, 71L); + for (int station = 0; station < 400; station++) { + assertEquals(3D, narrow.halfWidth()[station], 0D); + } + assertArrayEquals(profile.depth(), narrow.depth(), 0D); + } + + @Test + public void fixedWidthRangesAndMinimumConfinementRemainUnmodulated() { + HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface(); + HydrologyPlannerSettings.Surface fixed = new HydrologyPlannerSettings.Surface(defaults.enabled(), defaults.sources(), + 6, 6, defaults.minimumDepth(), defaults.maximumDepth(), defaults.maximumIncision(), defaults.shoreWidth(), defaults.banks()); + HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(80, 0D, "land"); + ChannelProfileBuilder fixedBuilder = new ChannelProfileBuilder(fixed, terrain, CONSTANT_GEOMETRY); + assertArrayEquals(fixedBuilder.build(straight(400), "water", false, 71L).halfWidth(), + fixedBuilder.build(straight(400), "water", false, 72L).halfWidth(), 0D); + ChannelProfileBuilder minimumBuilder = new ChannelProfileBuilder(defaults, terrain, request -> request.minimum()); + assertArrayEquals(minimumBuilder.build(straight(400), "water", false, 71L).halfWidth(), + minimumBuilder.build(straight(400), "water", false, 72L).halfWidth(), 0D); + assertArrayEquals(fixedBuilder.build(straight(400), null, false, 71L).halfWidth(), + fixedBuilder.build(straight(400), "", false, 71L).halfWidth(), 0D); + } + private static ChannelProfileBuilder builder( HydrologyPlannerSettings.Channel channel, HydrologyGeometrySampler geometry diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java index ac8e1a3f4..24e762a9a 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java @@ -21,6 +21,7 @@ import org.junit.Test; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; @@ -47,6 +48,79 @@ public class ErosionFieldCompilerTest { default -> request.minimum(); }; + @Test + public void shapedEndpointPondsStayConnectedInsideTheirValidatedCircularEnvelope() { + HydrologyPlannerSettings.Ponds ponds = new HydrologyPlannerSettings.Ponds( + new HydrologyPlannerSettings.Pond(true, 10, 10, 3), + new HydrologyPlannerSettings.Pond(true, 10, 10, 3)); + HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface(0, HydrologyPlannerSettings.Erosion.defaults(), ponds); + HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(80, 0D, "land"); + for (boolean rotated : List.of(false, true)) { + SurfaceCenterline centerline = SurfaceCenterline.densify(List.of(new HydrologyPoint(0, 80, 0), + new HydrologyPoint(rotated ? 0 : 127, 80, rotated ? 127 : 0))); + double[] widths = new double[centerline.size()]; + double[] depths = new double[centerline.size()]; + double[] banks = new double[centerline.size()]; + int[] heads = new int[centerline.size()]; + Arrays.fill(widths, 2D); + Arrays.fill(depths, 2D); + Arrays.fill(banks, 1D); + Arrays.fill(heads, 80); + ChannelProfile channel = new ChannelProfile(widths, depths, banks); + ValleyProfile valley = ValleyProfile.fromHeads(heads, heads.length); + ErosionFieldCompiler compiler = new ErosionFieldCompiler(rasterSettings(surface), terrain); + ErosionField field = compiler.compile(1234L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 0); + assertNull(field.rejection()); + assertEquals(0, field.uncontainedWetCells()); + assertChannelContained(new Compiled(field, valley)); + assertEquals(SurfaceRole.CHANNEL, field.column(0, 0).role()); + SurfaceColumn end = field.column(rotated ? 0 : 127, rotated ? 127 : 0); + assertEquals(SurfaceRole.CHANNEL, end.role()); + assertTrue(end.height() < end.headY()); + assertTrue(field.column(0, 0).height() < field.column(0, 0).headY()); + SurfaceColumn narrowSide = field.column(rotated ? 9 : 0, rotated ? 0 : 9); + assertTrue(narrowSide == null || narrowSide.role() != SurfaceRole.CHANNEL); + Set wet = new HashSet<>(); + for (SurfaceColumn column : field.columns().values()) { + if (column.role() != SurfaceRole.CHANNEL || column.height() >= column.headY() || column.apron()) { + continue; + } + wet.add(RiverFootprint.pack(column.x(), column.z())); + int along = rotated ? column.z() : column.x(); + int across = rotated ? column.x() : column.z(); + if (along < 0 || along > 127) { + int endpoint = along < 0 ? 0 : 127; + assertTrue(StrictMath.hypot(along - endpoint, across) <= 10.25D); + } + } + Set reached = new HashSet<>(); + ArrayDeque pending = new ArrayDeque<>(); + pending.add(RiverFootprint.pack(0, 0)); + reached.add(RiverFootprint.pack(0, 0)); + int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + while (!pending.isEmpty()) { + long current = pending.removeFirst(); + for (int[] offset : offsets) { + long next = RiverFootprint.pack(RiverFootprint.unpackX(current) + offset[0], + RiverFootprint.unpackZ(current) + offset[1]); + if (wet.contains(next) && reached.add(next)) { + pending.add(next); + } + } + } + assertEquals(wet, reached); + SurfaceBounds bounds = rotated ? new SurfaceBounds(-12, -12, 12, 64) + : new SurfaceBounds(-12, -12, 64, 12); + ErosionField clipped = compiler.compile(1234L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 0, + ponds, SurfaceRasterContext.bounded(bounds)); + for (SurfaceColumn column : field.columns().values()) { + if (bounds.contains(column.x(), column.z())) { + assertEquals(column, clipped.column(column.x(), column.z())); + } + } + } + } + @Test public void wetIncisionPreflightMatchesFullVerdictsAcrossPondsCrossingsStepsAndPolicies() { int accepted = 0; @@ -253,13 +327,13 @@ public class ErosionFieldCompilerTest { sampleCounts.add(samples.get()); } assertEquals(List.of( - "66595850077d44389c5c78594276de4568f85f08854d3333137dfc05ebf3455a", - "66595850077d44389c5c78594276de4568f85f08854d3333137dfc05ebf3455a", - "66595850077d44389c5c78594276de4568f85f08854d3333137dfc05ebf3455a", - "744b15e684ba996f9c730ab1409ada2643985c902a6da1aadb5cc2b4882d640b", - "2e7e4af1e433ccd0a7392f0d2e93bb85ac122088cd2cf8d55107f7dcfb4ec283", - "7beee0b78c37b1287510fa0719375e14e0f0013eb8af81df8e20a3b5628612b7", - "ad35016194318efa42dc862df4e2bb3d9ec398d3bacec79588576d74bfd1723a", + "1cf278d948dd80523cb79dceeabf5e3846a28c8ecd2595247f97de147b502aea", + "1cf278d948dd80523cb79dceeabf5e3846a28c8ecd2595247f97de147b502aea", + "1cf278d948dd80523cb79dceeabf5e3846a28c8ecd2595247f97de147b502aea", + "4632eab466aaf31be342e60e7ea285bd112564356e364e7876563c373027ab54", + "f64cb9e63d53728d55be395cf7f0cd5c88ef0402441434a64f79323d579f8a43", + "2da402c95dbd9d0568fec47479a943a020d1304a386653ba5b696d0e885dfff2", + "db2cd5eb7fd1d53fd9de167cda8e6c214652585162a937511c51596215b75fbf", "a2fe6d20c87be6b6ebf1f4503da75ee767e65d5c8e8c43d5c344957f48f3085a"), fingerprints); int[] previousSamples = {16724, 16724, 16724, 19594, 15164, 15464, 16724, 7367}; int[] maximumSamples = {15500, 15500, 15500, 17000, 3000, 3300, 5000, 7367}; @@ -400,7 +474,7 @@ public class ErosionFieldCompilerTest { HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface(); HydrologyTerrainSampler hillside = (int x, int z) -> HydrologyTerrainSample.openLand(90 + Math.max(0, z) / 2, 0D, "land"); SurfaceCenterline centerline = SurfaceCenterline.densify(List.of(new HydrologyPoint(-150, 0, 0), new HydrologyPoint(150, 0, 0))); - ChannelProfile channel = new ChannelProfileBuilder(surface, hillside, CONSTANT_GEOMETRY).build(centerline, "water", false); + ChannelProfile channel = new ChannelProfileBuilder(surface, hillside, CONSTANT_GEOMETRY).build(centerline, "water", false, 0L); ValleyProfile valley = new ValleyProfileSolver(surface, hillside, SEA_LEVEL, 64).solve(centerline, channel, SurfaceTerminal.SINKHOLE, 40); ErosionFieldCompiler compiler = new ErosionFieldCompiler(rasterSettings(surface), hillside); ErosionField complete = compiler.compile(42L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 8); @@ -745,7 +819,7 @@ public class ErosionFieldCompilerTest { List path = List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0)); SurfaceCenterline centerline = SurfaceCenterline.densify(path); ChannelProfile channel = new ChannelProfileBuilder(surface, sampler, CONSTANT_GEOMETRY) - .build(centerline, "water", terminal == SurfaceTerminal.OCEAN_MOUTH); + .build(centerline, "water", terminal == SurfaceTerminal.OCEAN_MOUTH, 0L); ValleyProfile valley = new ValleyProfileSolver(surface, sampler, SEA_LEVEL, 64) .solve(centerline, channel, terminal, terminalHead); assertNull(valley.rejection()); @@ -992,7 +1066,7 @@ public class ErosionFieldCompilerTest { List path = List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(259, 0, 0)); SurfaceCenterline centerline = SurfaceCenterline.densify(path); ChannelProfile channel = new ChannelProfileBuilder(surface, sampler, CONSTANT_GEOMETRY) - .build(centerline, "water", true); + .build(centerline, "water", true, 0L); ValleyProfile valley = new ValleyProfileSolver(surface, sampler, SEA_LEVEL, 64) .solve(centerline, channel, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); assertNull(valley.rejection()); @@ -1069,16 +1143,17 @@ public class ErosionFieldCompilerTest { assertEquals(SurfaceRole.CHANNEL, centre.role()); assertEquals(80, centre.headY()); assertTrue(centre.height() <= 77); - SurfaceColumn behind = compiled.field().column(-9, 0); + SurfaceColumn behind = compiled.field().column(-7, 0); assertNotNull(behind); assertEquals(SurfaceRole.CHANNEL, behind.role()); assertEquals(80, behind.headY()); - SurfaceColumn beside = compiled.field().column(0, 9); + SurfaceColumn beside = compiled.field().column(0, 6); assertEquals(SurfaceRole.CHANNEL, beside.role()); assertEquals(0, behind.station()); - SurfaceColumn plainBehind = plain.field().column(-9, 0); + SurfaceColumn plainBehind = plain.field().column(-7, 0); assertTrue(plainBehind == null || plainBehind.role() != SurfaceRole.CHANNEL); - assertEquals(SurfaceRole.SHORE, compiled.field().column(-11, 0).role()); + SurfaceColumn outside = compiled.field().column(-11, 0); + assertTrue(outside == null || outside.role() != SurfaceRole.CHANNEL); assertNotNull(compiled.field().column(150, 0)); assertEquals(SurfaceRole.CHANNEL, compiled.field().column(150, 0).role()); assertTrue(compiled.field().column(150, 4).role() == SurfaceRole.SHORE); @@ -1129,7 +1204,7 @@ public class ErosionFieldCompilerTest { assertChannelContained(compiled); assertBoundedBankFill(compiled, 8); assertEquals(80, compiled.field().column(0, 0).headY()); - SurfaceColumn nearRim = compiled.field().column(0, 4); + SurfaceColumn nearRim = compiled.field().column(0, 3); assertEquals(SurfaceRole.CHANNEL, nearRim.role()); assertTrue(compiled.field().columns().values().stream() .anyMatch(column -> column.height() > column.terrain().naturalHeight())); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintOrderingTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintOrderingTest.java new file mode 100644 index 000000000..97fcc8291 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintOrderingTest.java @@ -0,0 +1,44 @@ +package art.arcane.iris.generation.hydrology.surface; + +import art.arcane.iris.generation.hydrology.HydrologyTerrainSample; +import art.arcane.iris.generation.hydrology.RiverFootprint; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Random; + +import static org.junit.Assert.assertEquals; + +public class SurfaceFootprintOrderingTest { + @Test + public void sparseStationsPreserveCoordinateOrderAcrossSignedBoundaries() { + Random random = new Random(9482741L); + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(90, 0D, "land"); + for (int stationCount : new int[]{1, 2, 17, 1024}) { + Long2ObjectOpenHashMap columns = new Long2ObjectOpenHashMap<>(); + for (int index = 0; index < 4096; index++) { + int x = index - 2048; + int z = random.nextInt(); + int station = random.nextBoolean() ? stationCount - 1 : random.nextInt(stationCount); + SurfaceColumn column = new SurfaceColumn(x, z, terrain, station, SurfaceRole.CHANNEL, + 64, 68, false); + columns.put(RiverFootprint.pack(x, z), column); + } + ErosionField field = new ErosionField(columns, 0, null, 0, 0L); + ArrayList expected = new ArrayList<>(columns.values()); + expected.sort(Comparator.comparingInt(SurfaceColumn::station) + .thenComparingLong(column -> RiverFootprint.pack(column.x(), column.z()))); + assertEquals(expected, Arrays.asList(SurfaceFootprintCompiler.orderedColumns(field, stationCount))); + } + } + + @Test + public void anEmptyFieldNeedsNoColumns() { + ErosionField empty = new ErosionField(new Long2ObjectOpenHashMap<>(), 0, null, 0, 0L); + assertEquals(0, SurfaceFootprintCompiler.orderedColumns(empty, 0).length); + assertEquals(0, SurfaceFootprintCompiler.orderedColumns(empty, 1024).length); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ValleyProfileSolverTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ValleyProfileSolverTest.java index 5dac7fe10..32b6e6d2c 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ValleyProfileSolverTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ValleyProfileSolverTest.java @@ -29,7 +29,7 @@ public class ValleyProfileSolverTest { SurfaceCenterline centerline = SurfaceCenterline.densify(List.of( new HydrologyPoint(0, 0, 0), new HydrologyPoint(48, 0, 48))); ChannelProfile channel = new ChannelProfileBuilder(HydrologyPlannerSettings.defaults().surface(), - terrain, CONSTANT_GEOMETRY).build(centerline, "water", false); + terrain, CONSTANT_GEOMETRY).build(centerline, "water", false, 0L); ValleyProfile valley = solver(terrain).solve(centerline, channel, SurfaceTerminal.SINKHOLE, 40); @@ -55,7 +55,7 @@ public class ValleyProfileSolverTest { new HydrologyPoint(0, 0, 0), new HydrologyPoint(80, 0, 80))); HydrologyPlannerSettings.Surface surface = HydrologyPlannerSettings.defaults().surface(); ChannelProfile channel = new ChannelProfileBuilder(surface, terrain, CONSTANT_GEOMETRY) - .build(centerline, "water", false); + .build(centerline, "water", false, 0L); ValleyProfile valley = new ValleyProfileSolver(surface, terrain, SEA_LEVEL, 67) .solve(centerline, channel, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); @@ -482,7 +482,7 @@ public class ValleyProfileSolverTest { private static ChannelProfile channel(int stations, HydrologyTerrainSampler sampler) { return new ChannelProfileBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY) - .build(straight(stations), "water", false); + .build(straight(stations), "water", false, 0L); } private static SurfaceCenterline straight(int stations) { diff --git a/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorSurfaceDetailTest.java b/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorSurfaceDetailTest.java index a53040dea..65f600876 100644 --- a/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorSurfaceDetailTest.java +++ b/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorSurfaceDetailTest.java @@ -277,7 +277,7 @@ public class IrisGeneratorSurfaceDetailTest { } @Override - public double getNoise(long superSeed, double x, double z, IrisData data) { + public double getNoise(long superSeed, double x, double z, IrisData data, Engine engine) { return sampler.sample(superSeed, x, z); } } @@ -290,7 +290,7 @@ public class IrisGeneratorSurfaceDetailTest { } @Override - public double getNoise(long superSeed, double x, double z, IrisData data) { + public double getNoise(long superSeed, double x, double z, IrisData data, Engine engine) { return sampler.applyAsDouble(data); } } diff --git a/core/src/test/java/art/arcane/iris/generation/noise/IrisNoiseGeneratorCacheTest.java b/core/src/test/java/art/arcane/iris/generation/noise/IrisNoiseGeneratorCacheTest.java index bdfd95269..8b9879462 100644 --- a/core/src/test/java/art/arcane/iris/generation/noise/IrisNoiseGeneratorCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/noise/IrisNoiseGeneratorCacheTest.java @@ -3,6 +3,7 @@ package art.arcane.iris.generation.noise; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.Engine; import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import org.junit.Test; @@ -14,12 +15,16 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; public final class IrisNoiseGeneratorCacheTest { private static final long GENERATOR_SALT = 33_955_677L; @@ -30,17 +35,17 @@ public final class IrisNoiseGeneratorCacheTest { public void callerSeedsDoNotShareAFirstWinnerGenerator() { GeneratorFixture fixture = new GeneratorFixture(); - assertSame(fixture.firstGenerator, fixture.generator.getGenerator(11L, fixture.data)); - assertSame(fixture.secondGenerator, fixture.generator.getGenerator(29L, fixture.data)); - assertSame(fixture.firstGenerator, fixture.generator.getGenerator(11L, fixture.data)); + assertSame(fixture.firstGenerator, fixture.generator.getGenerator(11L, fixture.data, fixture.engine)); + assertSame(fixture.secondGenerator, fixture.generator.getGenerator(29L, fixture.data, fixture.engine)); + assertSame(fixture.firstGenerator, fixture.generator.getGenerator(11L, fixture.data, fixture.engine)); } @Test public void reverseInitializationOrderKeepsSeedAssignments() { GeneratorFixture fixture = new GeneratorFixture(); - assertSame(fixture.secondGenerator, fixture.generator.getGenerator(29L, fixture.data)); - assertSame(fixture.firstGenerator, fixture.generator.getGenerator(11L, fixture.data)); + assertSame(fixture.secondGenerator, fixture.generator.getGenerator(29L, fixture.data, fixture.engine)); + assertSame(fixture.firstGenerator, fixture.generator.getGenerator(11L, fixture.data, fixture.engine)); } @Test @@ -51,11 +56,11 @@ public final class IrisNoiseGeneratorCacheTest { try { Future first = executor.submit(() -> { assertTrue(start.await(5L, TimeUnit.SECONDS)); - return fixture.generator.getGenerator(11L, fixture.data); + return fixture.generator.getGenerator(11L, fixture.data, fixture.engine); }); Future second = executor.submit(() -> { assertTrue(start.await(5L, TimeUnit.SECONDS)); - return fixture.generator.getGenerator(29L, fixture.data); + return fixture.generator.getGenerator(29L, fixture.data, fixture.engine); }); start.countDown(); @@ -66,6 +71,53 @@ public final class IrisNoiseGeneratorCacheTest { } } + @Test + public void heightResolvesEngineOnceAcrossFracturesAndSurfaceCorners() { + IrisData data = mock(IrisData.class); + Engine engine = mock(Engine.class); + when(data.getEngine()).thenReturn(engine); + IrisNoiseGenerator child = new IrisNoiseGenerator().setStyle(NoiseStyle.SIMPLEX.style()); + IrisNoiseGenerator layer = new IrisNoiseGenerator().setStyle(NoiseStyle.SIMPLEX.style()) + .setFracture(new KList<>(child)); + IrisGenerator generator = new IrisGenerator().setComposite(new KList<>(layer, child)) + .setSurfaceDetail(0.5D); + generator.setLoader(data); + + double first = generator.getHeight(13D, -21D, 71L); + verify(data, times(1)).getEngine(); + assertEquals(Double.doubleToRawLongBits(first), + Double.doubleToRawLongBits(generator.getHeight(13D, -21D, 71L))); + verify(data, times(2)).getEngine(); + } + + @Test + public void changedEngineUsesDistinctConstructionAndRestoresPreviousGenerator() { + IrisData data = mock(IrisData.class); + Engine firstEngine = mock(Engine.class); + Engine secondEngine = mock(Engine.class); + IrisGeneratorStyle style = mock(IrisGeneratorStyle.class); + CNG first = mock(CNG.class); + CNG second = mock(CNG.class); + IrisNoiseGenerator layer = new IrisNoiseGenerator().setStyle(style); + when(data.getEngine()).thenReturn(firstEngine, secondEngine, firstEngine); + when(style.createForLayer(any(RNG.class), same(data), eq(1), same(firstEngine))).thenReturn(first); + when(style.createForLayer(any(RNG.class), same(data), eq(1), same(secondEngine))).thenReturn(second); + + assertSame(first, layer.getGenerator(11L, data, data.getEngine())); + assertSame(second, layer.getGenerator(11L, data, data.getEngine())); + assertSame(first, layer.getGenerator(11L, data, data.getEngine())); + verify(style, times(1)).createForLayer(any(RNG.class), same(data), eq(1), same(firstEngine)); + verify(style, times(1)).createForLayer(any(RNG.class), same(data), eq(1), same(secondEngine)); + } + + @Test + public void disabledNoiseDoesNotResolveEngine() { + IrisData data = mock(IrisData.class); + IrisNoiseGenerator layer = new IrisNoiseGenerator(false).setOffsetY(0.37D); + assertEquals(0.37D, layer.getNoise(11L, 13D, -21D, data), 0D); + verify(data, never()).getEngine(); + } + private static final class GeneratorFixture { private final IrisData data = mock(IrisData.class); private final Engine engine = mock(Engine.class); @@ -83,7 +135,7 @@ public final class IrisNoiseGeneratorCacheTest { 11L + GENERATOR_SALT - CONFIGURED_SEED, firstGenerator, 29L + GENERATOR_SALT - CONFIGURED_SEED, secondGenerator ); - when(style.createForLayer(any(RNG.class), same(data), eq(OCTAVES))).thenAnswer(invocation -> { + when(style.createForLayer(any(RNG.class), same(data), eq(OCTAVES), same(engine))).thenAnswer(invocation -> { RNG rng = invocation.getArgument(0); return generators.get(rng.getSeed()); }); diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index cfb640175..3cdb8e8c8 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -83,7 +83,7 @@ public class SchemaBuilderIdentityTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - private static final String EXPECTED_SCHEMA_DIGEST = "767e66a7a52bdd6ae9ad3f0ccfdcaf2cb509fc89c70bfffb4dcd54b565e643f9"; + private static final String EXPECTED_SCHEMA_DIGEST = "1720dcd78ce7d4ef6fc601cb8d8703d42a35625e8038ab04e98db03514df5959"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/studio/view/PregenMapPanelTest.java b/core/src/test/java/art/arcane/iris/studio/view/PregenMapPanelTest.java index 09595b594..95224f57a 100644 --- a/core/src/test/java/art/arcane/iris/studio/view/PregenMapPanelTest.java +++ b/core/src/test/java/art/arcane/iris/studio/view/PregenMapPanelTest.java @@ -7,12 +7,14 @@ import org.junit.Test; import javax.swing.SwingUtilities; import java.awt.Color; import java.awt.Rectangle; +import java.awt.event.MouseEvent; import java.awt.image.BufferedImage; import java.lang.reflect.Field; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class PregenMapPanelTest { @@ -78,6 +80,58 @@ public class PregenMapPanelTest { assertEquals(new Rectangle(251, 1, 300, 600), tall); } + @Test + public void hoverCoordinatesFollowPaintedRectangularBoundsAndLetterboxing() throws Exception { + PregenMapPanel wide = panel(new PregenRenderSnapshot.Bounds(-2, -3, 1, -2)); + SwingUtilities.invokeAndWait(() -> wide.setSize(802, 602)); + assertEquals("Chunk X -2 · Z -3", tooltip(wide, 1, 101)); + assertEquals("Chunk X -2 · Z -3", tooltip(wide, 200, 300)); + assertEquals("Chunk X -1 · Z -2", tooltip(wide, 201, 301)); + assertEquals("Chunk X 1 · Z -2", tooltip(wide, 800, 500)); + assertNull(tooltip(wide, 0, 300)); + assertNull(tooltip(wide, 801, 300)); + assertNull(tooltip(wide, 400, 100)); + assertNull(tooltip(wide, 400, 501)); + SwingUtilities.invokeAndWait(wide::disposeMap); + assertNull(tooltip(wide, 400, 300)); + + PregenMapPanel tall = panel(new PregenRenderSnapshot.Bounds(-7, -2, -6, 1)); + SwingUtilities.invokeAndWait(() -> tall.setSize(802, 602)); + assertEquals("Chunk X -7 · Z -2", tooltip(tall, 251, 1)); + assertEquals("Chunk X -6 · Z 1", tooltip(tall, 550, 600)); + assertNull(tooltip(tall, 250, 300)); + assertNull(tooltip(tall, 551, 300)); + SwingUtilities.invokeAndWait(tall::disposeMap); + } + + @Test + public void hoverCoordinatesPreserveSingleChunkAndExtremeIntegerBoundsAfterResize() throws Exception { + PregenMapPanel single = panel(new PregenRenderSnapshot.Bounds(5, -9, 5, -9)); + SwingUtilities.invokeAndWait(() -> single.setSize(64, 64)); + assertEquals("Chunk X 5 · Z -9", tooltip(single, 1, 1)); + assertEquals("Chunk X 5 · Z -9", tooltip(single, 62, 62)); + SwingUtilities.invokeAndWait(() -> single.setSize(128, 64)); + assertNull(tooltip(single, 1, 1)); + assertEquals("Chunk X 5 · Z -9", tooltip(single, 33, 1)); + SwingUtilities.invokeAndWait(single::disposeMap); + + PregenMapPanel extreme = panel(new PregenRenderSnapshot.Bounds( + Integer.MIN_VALUE, -10, Integer.MAX_VALUE, 10)); + SwingUtilities.invokeAndWait(() -> extreme.setSize(1026, 3)); + assertEquals("Chunk X -2147483648 · Z -10", tooltip(extreme, 1, 1)); + assertEquals("Chunk X 0 · Z -10", tooltip(extreme, 513, 1)); + assertEquals("Chunk X 2143289344 · Z -10", tooltip(extreme, 1024, 1)); + assertNull(tooltip(extreme, 1025, 1)); + SwingUtilities.invokeAndWait(extreme::disposeMap); + } + + private static String tooltip(PregenMapPanel panel, int x, int y) throws Exception { + String[] result = new String[1]; + SwingUtilities.invokeAndWait(() -> result[0] = panel.getToolTipText( + new MouseEvent(panel, MouseEvent.MOUSE_MOVED, 0L, 0, x, y, 0, false))); + return result[0]; + } + @Test public void arithmeticTaskBoundsMatchActualRectangularTraversal() { PregenTask task = PregenTask.builder().center(new Position2(-17, 35)).radiusX(9).radiusZ(37).build(); @@ -93,7 +147,7 @@ public class PregenMapPanelTest { private static PregenMapPanel panel(PregenRenderSnapshot.Bounds bounds) throws Exception { PregenMapPanel[] result = new PregenMapPanel[1]; - SwingUtilities.invokeAndWait(() -> result[0] = new PregenMapPanel(bounds)); + SwingUtilities.invokeAndWait(() -> result[0] = new PregenMapPanel(new PregenMapState(bounds))); return result[0]; } diff --git a/core/src/test/java/art/arcane/iris/studio/view/PregenMapRetentionTest.java b/core/src/test/java/art/arcane/iris/studio/view/PregenMapRetentionTest.java new file mode 100644 index 000000000..89fe9b5e4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/view/PregenMapRetentionTest.java @@ -0,0 +1,164 @@ +package art.arcane.iris.studio.view; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.world.pregen.CachedPregenMethod; +import art.arcane.iris.world.pregen.PregenCache; +import art.arcane.iris.world.pregen.PregenSavedChunkStatus; +import art.arcane.iris.world.pregen.PregenTask; +import art.arcane.iris.world.pregen.PregeneratorMethod; +import art.arcane.volmlib.util.math.Position2; +import org.junit.Test; +import org.mockito.MockedStatic; + +import javax.swing.SwingUtilities; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class PregenMapRetentionTest { + @Test + public void existingStartingChunksPublishedBeforeAttachmentAppearInActualPaintAndReopenedView() throws Exception { + Fixture fixture = new Fixture(); + fixture.job.onChunkExistsInRegionGen(-16, -16); + fixture.job.onChunkGenerated(-17, -16, false); + verifyNoInteractions(fixture.engine); + + PregenMapPanel first = panel(fixture.state); + assertEquals(PregenRenderer.EXISTS.getRGB(), paint(first, fixture.state).getRGB(17, 17)); + assertEquals(PregenRenderer.GENERATED.getRGB(), paint(first, fixture.state).getRGB(16, 17)); + SwingUtilities.invokeAndWait(first::disposeMap); + fixture.job.onChunkGenerated(-16, -17, true); + PregenMapPanel reopened = panel(fixture.state); + assertEquals(PregenRenderer.EXISTS.getRGB(), paint(reopened, fixture.state).getRGB(17, 17)); + assertEquals(PregenRenderer.GENERATED.getRGB(), paint(reopened, fixture.state).getRGB(17, 16)); + SwingUtilities.invokeAndWait(reopened::disposeMap); + } + + @Test + public void whollyCachedNegativeRegionPublishesAllChunksBeforeGuiOpens() throws Exception { + Fixture fixture = new Fixture(); + PregenCache cache = mock(PregenCache.class); + when(cache.sync()).thenReturn(cache); + when(cache.isRegionCached(-1, -1)).thenReturn(true); + PregeneratorMethod backend = mock(PregeneratorMethod.class); + CachedPregenMethod cached = new CachedPregenMethod(new CachedPregenMethod.Configuration( + backend, cache, fixture.task, new PregenSavedChunkStatus((x, z) -> true))); + try (MockedStatic services = mockStatic(IrisServices.class)) { + cached.generateRegion(-1, -1, fixture.job); + } + verifyNoInteractions(backend); + PregenMapPanel panel = panel(fixture.state); + BufferedImage rendered = paint(panel, fixture.state); + for (int x = 1; x < 32; x++) { + for (int z = 1; z < 32; z++) { + assertEquals(PregenRenderer.GENERATED.getRGB(), rendered.getRGB(x + 1, z + 1)); + } + } + SwingUtilities.invokeAndWait(panel::disposeMap); + } + + @Test + public void shutdownSkippedRegionsStayWaitingInsteadOfBeingPaintedAsExisting() throws Exception { + Fixture fixture = new Fixture(); + fixture.job.onRegionSkipped(-1, -1); + PregenMapPanel panel = panel(fixture.state); + assertEquals(PregenMapState.WAITING.getRGB(), paint(panel, fixture.state).getRGB(17, 17)); + SwingUtilities.invokeAndWait(panel::disposeMap); + } + + @Test + public void concurrentPublicationAndIndependentViewsKeepTheFinalRaster() throws Exception { + PregenMapState state = new PregenMapState(new PregenRenderSnapshot.Bounds(-8, -8, 7, 7)); + PregenMapPanel first = panel(state); + PregenMapPanel second = panel(state); + CountDownLatch started = new CountDownLatch(1); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future updates = worker.submit(() -> { + started.countDown(); + for (int round = 0; round < 100; round++) { + for (int x = -8; x < 8; x++) { + for (int z = -8; z < 8; z++) { + state.submit(x, z, (round & 1) == 0 ? Color.RED : Color.GREEN); + } + } + } + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + for (int round = 0; round < 10; round++) { + SwingUtilities.invokeAndWait(first::flush); + } + updates.get(5, TimeUnit.SECONDS); + BufferedImage one = paint(first, state); + BufferedImage two = paint(second, state); + for (int x = 2; x < 16; x++) { + for (int z = 2; z < 16; z++) { + assertEquals(Color.GREEN.getRGB(), one.getRGB(x, z)); + assertEquals(one.getRGB(x, z), two.getRGB(x, z)); + } + } + } finally { + worker.shutdownNow(); + SwingUtilities.invokeAndWait(first::disposeMap); + SwingUtilities.invokeAndWait(second::disposeMap); + } + } + + private static PregenMapPanel panel(PregenMapState state) throws Exception { + PregenMapPanel[] result = new PregenMapPanel[1]; + SwingUtilities.invokeAndWait(() -> result[0] = new PregenMapPanel(state)); + return result[0]; + } + + private static BufferedImage paint(PregenMapPanel panel, PregenMapState state) throws Exception { + BufferedImage image = new BufferedImage(state.width() + 2, state.height() + 2, BufferedImage.TYPE_INT_RGB); + SwingUtilities.invokeAndWait(() -> { + panel.flush(); + panel.setSize(image.getWidth(), image.getHeight()); + Graphics2D graphics = image.createGraphics(); + try { + panel.paint(graphics); + } finally { + graphics.dispose(); + } + }); + return image; + } + + private static final class Fixture { + private final PregeneratorJob job = mock(PregeneratorJob.class, CALLS_REAL_METHODS); + private final Engine engine = mock(Engine.class); + private final PregenTask task = PregenTask.builder().center(new Position2(-256, -256)) + .radiusX(256).radiusZ(256).build(); + private final PregenMapState state; + + private Fixture() throws Exception { + int[] bounds = task.chunkBounds(); + state = new PregenMapState(new PregenRenderSnapshot.Bounds(bounds[0], bounds[1], bounds[2], bounds[3])); + set("mapState", state); + set("task", task); + set("engine", engine); + } + + private void set(String name, Object value) throws Exception { + Field field = PregeneratorJob.class.getDeclaredField(name); + field.setAccessible(true); + field.set(job, value); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/view/PregenRendererTest.java b/core/src/test/java/art/arcane/iris/studio/view/PregenRendererTest.java new file mode 100644 index 000000000..d18db3c06 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/view/PregenRendererTest.java @@ -0,0 +1,200 @@ +package art.arcane.iris.studio.view; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.localization.DesktopUiMessages; +import art.arcane.iris.localization.IrisLanguage; +import art.arcane.iris.spi.IrisLogging; +import org.junit.Test; +import org.mockito.MockedStatic; + +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JTextArea; +import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; +import java.awt.event.ActionEvent; +import java.awt.event.KeyEvent; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mockStatic; + +public class PregenRendererTest { + @Test + public void keyboardAndButtonActivationToggleExactlyOnceAndTerminalControlsAreDisabled() throws Exception { + onEdt(() -> { + Source source = new Source(); + AtomicInteger calls = new AtomicInteger(); + PregenRenderer renderer = renderer(source, () -> { + calls.incrementAndGet(); + source.snapshot = snapshot(source.snapshot.phase() == PregenRenderSnapshot.Phase.PAUSED + ? PregenRenderSnapshot.Phase.GENERATING : PregenRenderSnapshot.Phase.PAUSED, null, 0); + }); + try { + JButton button = field(renderer, "pauseButton", JButton.class); + Object pauseAction = renderer.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_P, 0, true)); + renderer.getActionMap().get(pauseAction).actionPerformed(new ActionEvent(renderer, 0, "P")); + assertEquals(1, calls.get()); + assertEquals(PregenRenderSnapshot.Phase.PAUSED, source.snapshot.phase()); + Object enterAction = button.getInputMap(JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0)); + button.getActionMap().get(enterAction).actionPerformed(new ActionEvent(button, 0, "Enter")); + assertEquals(2, calls.get()); + button.doClick(0); + assertEquals(3, calls.get()); + for (PregenRenderSnapshot.Phase phase : new PregenRenderSnapshot.Phase[]{ + PregenRenderSnapshot.Phase.STOPPING, PregenRenderSnapshot.Phase.COMPLETED, + PregenRenderSnapshot.Phase.ERROR}) { + source.snapshot = snapshot(phase, null, 0); + refresh(renderer); + assertFalse(button.isEnabled()); + renderer.getActionMap().get(pauseAction).actionPerformed(new ActionEvent(renderer, 0, "P")); + button.getActionMap().get(enterAction).actionPerformed(new ActionEvent(button, 0, "Enter")); + button.doClick(0); + assertEquals(3, calls.get()); + } + } finally { + renderer.close(); + } + }); + } + + @Test + public void initializingAndPausedEtaRemainPending() throws Exception { + onEdt(() -> { + Source source = new Source(); + source.snapshot = snapshot(PregenRenderSnapshot.Phase.INITIALIZING, null, 0); + PregenRenderer renderer = renderer(source, () -> { }); + try { + JLabel eta = field(renderer, "etaValue", JLabel.class); + String pending = IrisLanguage.plain(DesktopUiMessages.PREGEN_METHOD_PENDING); + assertEquals(pending, eta.getText()); + source.snapshot = snapshot(PregenRenderSnapshot.Phase.PAUSED, null, 0); + refresh(renderer); + assertEquals(pending, eta.getText()); + for (PregenRenderSnapshot.Phase phase : new PregenRenderSnapshot.Phase[]{ + PregenRenderSnapshot.Phase.ERROR, PregenRenderSnapshot.Phase.STOPPING}) { + source.snapshot = snapshot(phase, null, 0); + refresh(renderer); + assertEquals(pending, eta.getText()); + } + source.snapshot = snapshot(PregenRenderSnapshot.Phase.GENERATING, null, 0); + refresh(renderer); + assertFalse(pending.equals(eta.getText())); + } finally { + renderer.close(); + } + }); + } + + @Test + public void failureDetailsAndPauseFailureRemainVisibleUntilSuccessfulRetry() throws Exception { + onEdt(() -> { + Source source = new Source(); + source.snapshot = snapshot(PregenRenderSnapshot.Phase.GENERATING, "chunk save failed", 1); + AtomicBoolean failPause = new AtomicBoolean(true); + PregenRenderer renderer = renderer(source, () -> { + if (failPause.get()) { + throw new IllegalStateException("scheduler refused pause"); + } + }); + try (MockedStatic logging = mockStatic(IrisLogging.class, invocation -> { + if (invocation.getMethod().getName().equals("reportError") + && invocation.getMethod().getParameterCount() == 2 + && invocation.getMethod().getParameterTypes()[0] == String.class + && invocation.getMethod().getParameterTypes()[1] == Throwable.class) { + return null; + } + return invocation.callRealMethod(); + })) { + JButton button = field(renderer, "pauseButton", JButton.class); + JTextArea details = field(renderer, "failureLabel", JTextArea.class); + assertTrue(details.getLineWrap()); + assertTrue(details.getText().contains("chunk save failed")); + int selectionStart = details.getText().indexOf("chunk save failed"); + details.select(selectionStart, selectionStart + "chunk save failed".length()); + PregenRenderSnapshot previous = source.snapshot; + source.snapshot = new PregenRenderSnapshot(previous.bounds(), previous.progress(), + previous.phase(), previous.cached(), previous.usedMemoryBytes() + 1024, + previous.memoryUsage(), previous.allocationBytesPerSecond(), previous.failure()); + refresh(renderer); + assertEquals("chunk save failed", details.getSelectedText()); + assertEquals(selectionStart, details.getSelectionStart()); + button.doClick(0); + assertTrue(details.getText().contains("scheduler refused pause")); + source.snapshot = snapshot(PregenRenderSnapshot.Phase.GENERATING, null, 2); + refresh(renderer); + assertTrue(details.getText().contains("scheduler refused pause")); + failPause.set(false); + button.doClick(0); + assertFalse(details.getText().contains("scheduler refused pause")); + } finally { + renderer.close(); + } + }); + } + + private static PregenRenderer renderer(Source source, Runnable pause) throws Exception { + Constructor constructor = PregenRenderer.class.getDeclaredConstructor( + PregenRenderSource.class, Runnable.class); + constructor.setAccessible(true); + return constructor.newInstance(source, pause); + } + + private static void refresh(PregenRenderer renderer) throws Exception { + Method refresh = PregenRenderer.class.getDeclaredMethod("refresh"); + refresh.setAccessible(true); + refresh.invoke(renderer); + } + + private static T field(PregenRenderer renderer, String name, Class type) throws Exception { + Field field = PregenRenderer.class.getDeclaredField(name); + field.setAccessible(true); + return type.cast(field.get(renderer)); + } + + private static void onEdt(CheckedTask task) throws Exception { + SwingUtilities.invokeAndWait(() -> { + try (MockedStatic settings = mockStatic(IrisSettings.class)) { + settings.when(IrisSettings::get).thenReturn(new IrisSettings()); + task.run(); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + }); + } + + private static PregenRenderSnapshot snapshot(PregenRenderSnapshot.Phase phase, String failure, long failed) { + return new PregenRenderSnapshot(new PregenRenderSnapshot.Bounds(-32, -32, 32, 32), + new PregeneratorJob.PregenProgress(25, 25, 100, 20, 18, 17, 16, + 75, 4, 3000, 1000, "native", phase == PregenRenderSnapshot.Phase.PAUSED, + failed, "Example world", "example"), phase, false, 1024, 0.25, 256, failure); + } + + private interface CheckedTask { + void run() throws Exception; + } + + private static final class Source implements PregenRenderSource { + private PregenRenderSnapshot snapshot = snapshot(PregenRenderSnapshot.Phase.GENERATING, null, 0); + private final PregenMapState map = new PregenMapState(snapshot.bounds()); + + @Override + public PregenRenderSnapshot renderSnapshot() { + return snapshot; + } + + @Override + public PregenMapState renderMapState() { + return map; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/view/PregeneratorJobSavedBiomeTest.java b/core/src/test/java/art/arcane/iris/studio/view/PregeneratorJobSavedBiomeTest.java index a308dc875..316251234 100644 --- a/core/src/test/java/art/arcane/iris/studio/view/PregeneratorJobSavedBiomeTest.java +++ b/core/src/test/java/art/arcane/iris/studio/view/PregeneratorJobSavedBiomeTest.java @@ -33,7 +33,7 @@ public class PregeneratorJobSavedBiomeTest { fixture.job.onChunkGenerated(2, -3, false); assertEquals(1, fixture.tasks.size()); fixture.tasks.removeFirst().run(); - verify(fixture.renderer).submit(anyInt(), anyInt(), any()); + assertEquals(PregenRenderer.GENERATED.getRGB(), fixture.mapPixels[0]); } @Test @@ -92,11 +92,16 @@ public class PregeneratorJobSavedBiomeTest { private final Engine engine = mock(Engine.class, CALLS_REAL_METHODS); private final PregenRenderer renderer = mock(PregenRenderer.class); private final ArrayList tasks = new ArrayList<>(); + private final int[] mapPixels = new int[1]; private Fixture() throws Exception { + PregenMapState state = new PregenMapState(new PregenRenderSnapshot.Bounds(2, -3, 2, -3)); + PregenMapState.View view = state.attach(mapPixels); + setField("mapState", state); ExecutorService service = mock(ExecutorService.class); doAnswer(invocation -> { tasks.add(invocation.getArgument(0)); + view.flush(); return null; }).when(service).execute(any(Runnable.class)); when(renderer.isVisibleFrame()).thenReturn(true); diff --git a/core/src/test/java/art/arcane/iris/world/history/BoundaryColumnGeometryTest.java b/core/src/test/java/art/arcane/iris/world/history/BoundaryColumnGeometryTest.java index 7395bb8e6..545086801 100644 --- a/core/src/test/java/art/arcane/iris/world/history/BoundaryColumnGeometryTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/BoundaryColumnGeometryTest.java @@ -24,6 +24,50 @@ public class BoundaryColumnGeometryTest { private static final BoundaryColumnGeometry.Voxel AIR = new BoundaryColumnGeometry.Voxel( "minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false); + @Test + public void solidAboveMatchesDenseColumnsWithProtectionAndExtremeWorldCoordinates() { + BoundaryColumnGeometry.Voxel protectedSolid = new BoundaryColumnGeometry.Voxel( + "minecraft:gold_block", BoundaryColumnGeometry.Phase.SOLID, "", true); + BoundaryColumnGeometry.Voxel fluid = new BoundaryColumnGeometry.Voxel( + "minecraft:water", BoundaryColumnGeometry.Phase.FLUID, "minecraft:water", false); + Random random = new Random(83761L); + for (int height : new int[]{0, 1, 2, 3, 64, 257, 4096, BoundaryColumnGeometry.MAXIMUM_HEIGHT}) { + for (int minimumY : new int[]{Integer.MIN_VALUE, -2032, -64, 0, + Integer.MAX_VALUE - BoundaryColumnGeometry.MAXIMUM_HEIGHT}) { + ArrayList voxels = new ArrayList<>(height); + for (int offset = 0; offset < height; offset++) { + voxels.add(switch (random.nextInt(8)) { + case 0 -> STONE; + case 1 -> protectedSolid; + case 2 -> fluid; + default -> AIR; + }); + } + BoundaryColumnGeometry geometry = BoundaryColumnGeometry.fromVoxels(minimumY, voxels); + for (int index = 0; index < 128; index++) { + long offset = index < 4 ? new long[]{-1, 0, height - 1L, height}[index] + : random.nextInt(Math.max(1, height + 2)) - 1L; + long worldY = (long) minimumY + offset; + if (worldY < Integer.MIN_VALUE || worldY > Integer.MAX_VALUE) { + continue; + } + boolean expected = false; + for (int above = (int) Math.max(0L, offset + 1L); above < height; above++) { + if (voxels.get(above).phase() == BoundaryColumnGeometry.Phase.SOLID) { + expected = true; + break; + } + } + assertEquals(expected, geometry.hasSolidAbove((int) worldY)); + } + assertFalse(geometry.hasSolidAbove(Integer.MAX_VALUE)); + } + } + assertFalse(BoundaryColumnGeometry.empty().hasSolidAbove(Integer.MIN_VALUE)); + assertFalse(BoundaryColumnGeometry.fromVoxels(-64, List.of(AIR, fluid)).hasSolidAbove(Integer.MIN_VALUE)); + assertTrue(BoundaryColumnGeometry.fromVoxels(-64, List.of(protectedSolid)).hasSolidAbove(-65)); + } + @Test public void preservesEveryVerticalRunIncludingCavesAndIslands() { BoundaryColumnGeometry geometry = BoundaryColumnGeometry.fromVoxels(-4,