Refine hydrology and Studio

- Add dimension-wide hydrology density controls.
- Validate pond containment and shorelines at block resolution.
- Prioritize requested water plans and deduplicate prefetch work.
- Keep completed water previews visible while details load.
- Add pregeneration map navigation and Studio theme controls.
- Reject pack mutations during generation-registry capture.
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-26 00:08:25 -04:00
1 parent 1d182769d8
commit 494cdb1a5c
61 files changed
+1624 -325

No files matched your search

@@ -17,6 +17,11 @@ public class IrisHydrology {
@Description("Turns rivers, streams, ponds, lakes and underground water on for this dimension.") @Description("Turns rivers, streams, ponds, lakes and underground water on for this dimension.")
private boolean enabled = false; private boolean enabled = false;
@MinNumber(0)
@MaxNumber(1)
@Description("Fraction of eligible water features to keep after local placement probabilities. 0.5 keeps half the candidate river sources, ponds, lakes and underground water features without changing their configured sizes.")
private double density = 1D;
@Description("How water drains across the terrain before rivers and ponds are placed.") @Description("How water drains across the terrain before rivers and ponds are placed.")
private IrisHydrologyDrainage drainage = new IrisHydrologyDrainage(); private IrisHydrologyDrainage drainage = new IrisHydrologyDrainage();
@@ -60,6 +60,7 @@ public final class HydrologyRuntime {
private static final int CHUNKS_PER_WINDOW = WindowKey.SIZE >> 4; private static final int CHUNKS_PER_WINDOW = WindowKey.SIZE >> 4;
public static final int MAX_PREGENERATION_REGIONS = 4096; public static final int MAX_PREGENERATION_REGIONS = 4096;
private static final int MAX_PREVIEW_REQUESTS = 64; private static final int MAX_PREVIEW_REQUESTS = 64;
private static final int PREFETCH_CAPACITY = 16;
private static final HydrologyRuntime DISABLED = new HydrologyRuntime(); private static final HydrologyRuntime DISABLED = new HydrologyRuntime();
private final HydrologySettings settings; private final HydrologySettings settings;
@@ -74,6 +75,7 @@ public final class HydrologyRuntime {
private final RasterFailures windowPreviewFailures; private final RasterFailures windowPreviewFailures;
private final RasterFailures regionPreviewFailures; private final RasterFailures regionPreviewFailures;
private final Set<Object> previews; private final Set<Object> previews;
private final LinkedHashMap<WindowKey, WindowPrefetch> prefetches;
private final HydrologyBinding binding; private final HydrologyBinding binding;
private final Function<PreparedHydrologyCacheIdentity, HydrologyPlanStore> stores; private final Function<PreparedHydrologyCacheIdentity, HydrologyPlanStore> stores;
private final boolean caveFluidPolicies; private final boolean caveFluidPolicies;
@@ -94,6 +96,7 @@ public final class HydrologyRuntime {
this.windowPreviewFailures = null; this.windowPreviewFailures = null;
this.regionPreviewFailures = null; this.regionPreviewFailures = null;
this.previews = null; this.previews = null;
this.prefetches = null;
this.binding = HydrologyBinding.UNBOUND; this.binding = HydrologyBinding.UNBOUND;
this.stores = identity -> null; this.stores = identity -> null;
this.caveFluidPolicies = false; this.caveFluidPolicies = false;
@@ -119,6 +122,7 @@ public final class HydrologyRuntime {
this.windowPreviewFailures = new RasterFailures(ticker); this.windowPreviewFailures = new RasterFailures(ticker);
this.regionPreviewFailures = new RasterFailures(ticker); this.regionPreviewFailures = new RasterFailures(ticker);
this.previews = new HashSet<>(); this.previews = new HashSet<>();
this.prefetches = new LinkedHashMap<>(PREFETCH_CAPACITY * 2, 0.75f, true);
this.binding = Objects.requireNonNull(binding, "binding"); this.binding = Objects.requireNonNull(binding, "binding");
this.stores = Objects.requireNonNull(stores, "stores"); this.stores = Objects.requireNonNull(stores, "stores");
this.caveFluidPolicies = caveFluidPolicies; this.caveFluidPolicies = caveFluidPolicies;
@@ -135,6 +139,9 @@ public final class HydrologyRuntime {
engine.getHeight()); engine.getHeight());
boolean caveFluidPolicies = settings.underground().cavePools().enabled() boolean caveFluidPolicies = settings.underground().cavePools().enabled()
&& requireBiomeFluids(dimension.getReachableBiomes(engine), settings); && requireBiomeFluids(dimension.getReachableBiomes(engine), settings);
if (settings.density() == 0D) {
return DISABLED;
}
HydrologyBinding binding = binding(engine, complex); HydrologyBinding binding = binding(engine, complex);
IrisHydrologyTerrainSource source = new IrisHydrologyTerrainSource(complex, dimension, settings, MultiBurst.hydrology, IrisHydrologyTerrainSource source = new IrisHydrologyTerrainSource(complex, dimension, settings, MultiBurst.hydrology,
MultiBurst.hydrology.parallelism(), binding); MultiBurst.hydrology.parallelism(), binding);
@@ -203,16 +210,24 @@ public final class HydrologyRuntime {
return settings; return settings;
} }
public PreviewScope preview() {
return new PreviewScope(cache == null ? null : last.get());
}
public ChunkWaterRaster raster(int chunkX, int chunkZ) { public ChunkWaterRaster raster(int chunkX, int chunkZ) {
if (cache == null) { if (cache == null) {
return ChunkWaterRaster.EMPTY; return ChunkWaterRaster.EMPTY;
} }
LastRaster slot = last.get();
if (slot.preview) {
return rasterPreview(chunkX, chunkZ);
}
if (mainThread.getAsBoolean()) { if (mainThread.getAsBoolean()) {
return rasterSnapshot(chunkX, chunkZ); return rasterSnapshot(chunkX, chunkZ);
} }
requireOpen(); requireOpen();
long key = key(chunkX, chunkZ); long key = key(chunkX, chunkZ);
ChunkWaterRaster known = remembered(key); ChunkWaterRaster known = remembered(key, slot);
return known != null ? known : remember(key, planned(chunkX, chunkZ, key)); return known != null ? known : remember(key, planned(chunkX, chunkZ, key));
} }
@@ -224,7 +239,7 @@ public final class HydrologyRuntime {
return ChunkWaterRaster.PROVISIONAL; return ChunkWaterRaster.PROVISIONAL;
} }
long key = key(chunkX, chunkZ); long key = key(chunkX, chunkZ);
ChunkWaterRaster known = remembered(key); ChunkWaterRaster known = remembered(key, last.get());
if (known == null) { if (known == null) {
ChunkWaterRaster landed = queue(chunkX, chunkZ, key, false); ChunkWaterRaster landed = queue(chunkX, chunkZ, key, false);
return landed == null ? ChunkWaterRaster.PROVISIONAL : landed; return landed == null ? ChunkWaterRaster.PROVISIONAL : landed;
@@ -240,7 +255,7 @@ public final class HydrologyRuntime {
return ChunkWaterRaster.PROVISIONAL; return ChunkWaterRaster.PROVISIONAL;
} }
long key = key(chunkX, chunkZ); long key = key(chunkX, chunkZ);
ChunkWaterRaster known = remembered(key); ChunkWaterRaster known = remembered(key, last.get());
if (known == null) { if (known == null) {
ChunkWaterRaster landed = queue(chunkX, chunkZ, key, true); ChunkWaterRaster landed = queue(chunkX, chunkZ, key, true);
return landed == null ? ChunkWaterRaster.PROVISIONAL : landed; return landed == null ? ChunkWaterRaster.PROVISIONAL : landed;
@@ -426,6 +441,9 @@ public final class HydrologyRuntime {
synchronized (rasters) { synchronized (rasters) {
rasters.clear(); rasters.clear();
} }
synchronized (prefetches) {
prefetches.clear();
}
} }
private static HydrologyBinding binding(Engine engine, IrisComplex complex) { private static HydrologyBinding binding(Engine engine, IrisComplex complex) {
@@ -532,7 +550,7 @@ public final class HydrologyRuntime {
queued.remove(key); queued.remove(key);
return null; return null;
} }
ChunkWaterRaster landed = remembered(key); ChunkWaterRaster landed = remembered(key, last.get());
if (landed != null) { if (landed != null) {
finish(key, preview); finish(key, preview);
return landed; return landed;
@@ -632,8 +650,22 @@ public final class HydrologyRuntime {
} }
private void prefetch(int chunkX, int chunkZ) { private void prefetch(int chunkX, int chunkZ) {
if (settings.prefetchRings() > 0) { if (settings.prefetchRings() <= 0) {
cache.prepareWindowsAround(chunkX, chunkZ, chunkX, chunkZ, 1 + settings.prefetchRings()); return;
}
WindowKey owner = WindowKey.containing(chunkX << 4, chunkZ << 4);
synchronized (prefetches) {
long evictions = cache.windowEvictions();
WindowPrefetch known = prefetches.get(owner);
if (known != null && !known.prepared().isCompletedExceptionally()
&& (!known.prepared().isDone() || known.evictions() == evictions)) {
return;
}
CompletableFuture<Void> prepared = cache.prepareWindowsAround(chunkX, chunkZ, chunkX, chunkZ, 1 + settings.prefetchRings());
prefetches.put(owner, new WindowPrefetch(prepared, evictions));
if (prefetches.size() > PREFETCH_CAPACITY) {
prefetches.pollFirstEntry();
}
} }
} }
@@ -648,8 +680,7 @@ public final class HydrologyRuntime {
return keys; return keys;
} }
private ChunkWaterRaster remembered(long key) { private ChunkWaterRaster remembered(long key, LastRaster slot) {
LastRaster slot = last.get();
if (slot.raster != null && slot.key == key) { if (slot.raster != null && slot.key == key) {
return slot.raster; return slot.raster;
} }
@@ -712,9 +743,13 @@ public final class HydrologyRuntime {
} }
} }
private record WindowPrefetch(CompletableFuture<Void> prepared, long evictions) {
}
private static final class LastRaster { private static final class LastRaster {
private long key; private long key;
private ChunkWaterRaster raster; private ChunkWaterRaster raster;
private boolean preview;
private void remember(long key, ChunkWaterRaster raster) { private void remember(long key, ChunkWaterRaster raster) {
this.key = key; this.key = key;
@@ -722,6 +757,30 @@ public final class HydrologyRuntime {
} }
} }
public static final class PreviewScope implements AutoCloseable {
private final LastRaster slot;
private final boolean previous;
private boolean closed;
private PreviewScope(LastRaster slot) {
this.slot = slot;
this.previous = slot != null && slot.preview;
if (slot != null) {
slot.preview = true;
}
}
@Override
public void close() {
if (!closed) {
closed = true;
if (slot != null) {
slot.preview = previous;
}
}
}
}
private static final class RasterLru extends LinkedHashMap<Long, ChunkWaterRaster> { private static final class RasterLru extends LinkedHashMap<Long, ChunkWaterRaster> {
private RasterLru() { private RasterLru() {
super(RASTER_CAPACITY * 2, 0.75f, true); super(RASTER_CAPACITY * 2, 0.75f, true);
@@ -225,8 +225,11 @@ public final class HeightLattice {
} }
} }
private static Long key(int cellX, int cellZ) { static long key(int cellX, int cellZ) {
return ((long) cellX << 32) | (cellZ & 0xFFFFFFFFL); long packed = ((long) cellX << 32) | (cellZ & 0xFFFFFFFFL);
long mixed = (packed ^ (packed >>> 30)) * 0xbf58476d1ce4e5b9L;
mixed = (mixed ^ (mixed >>> 27)) * 0x94d049bb133111ebL;
return mixed ^ (mixed >>> 31);
} }
private record Slot(CompletableFuture<HydrologySampleBuffer> batch, int index) { private record Slot(CompletableFuture<HydrologySampleBuffer> batch, int index) {
@@ -1,5 +1,6 @@
package art.arcane.iris.generation.hydrology.plan; package art.arcane.iris.generation.hydrology.plan;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.hydrology.raster.ReachIndex; import art.arcane.iris.generation.hydrology.raster.ReachIndex;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
@@ -20,7 +21,9 @@ import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import java.util.function.BiFunction; import java.util.function.BiFunction;
@@ -46,6 +49,7 @@ public final class HydrologyPlanCache {
private volatile HydrologyPlanStore store; private volatile HydrologyPlanStore store;
private int writing; private int writing;
private final ExecutorService pool; private final ExecutorService pool;
private final int speculativeParallelism;
private final BooleanSupplier mainThread; private final BooleanSupplier mainThread;
private final Object lock = new Object(); private final Object lock = new Object();
private final PriorityQueue<Job> queue = new PriorityQueue<>(Comparator.comparingLong((Job job) -> job.priority).thenComparingLong(job -> job.order)); private final PriorityQueue<Job> queue = new PriorityQueue<>(Comparator.comparingLong((Job job) -> job.priority).thenComparingLong(job -> job.order));
@@ -56,6 +60,7 @@ public final class HydrologyPlanCache {
private final Shelf<RegionKey, RegionPlan> regions = new Shelf<>(REGION_CAPACITY, this::startRegion); private final Shelf<RegionKey, RegionPlan> regions = new Shelf<>(REGION_CAPACITY, this::startRegion);
private final Shelf<WindowKey, IndexedWindow> windows = new Shelf<>(WINDOW_CAPACITY, this::startWindow); private final Shelf<WindowKey, IndexedWindow> windows = new Shelf<>(WINDOW_CAPACITY, this::startWindow);
private final Map<Object, Set<Slot<?, ?>>> epochSlots = new HashMap<>(); private final Map<Object, Set<Slot<?, ?>>> epochSlots = new HashMap<>();
private int runningSpeculative;
private volatile boolean closed; private volatile boolean closed;
public HydrologyPlanCache(HydrologySettings settings, Function<RegionKey, RegionPlan> regionPlanner, public HydrologyPlanCache(HydrologySettings settings, Function<RegionKey, RegionPlan> regionPlanner,
@@ -64,6 +69,7 @@ public final class HydrologyPlanCache {
this.windowPlanner = Objects.requireNonNull(windowPlanner, "windowPlanner"); this.windowPlanner = Objects.requireNonNull(windowPlanner, "windowPlanner");
this.persist = Objects.requireNonNull(settings, "settings").persist(); this.persist = Objects.requireNonNull(settings, "settings").persist();
this.pool = Objects.requireNonNull(pool, "pool"); this.pool = Objects.requireNonNull(pool, "pool");
this.speculativeParallelism = speculativeParallelism(pool);
this.mainThread = Objects.requireNonNull(mainThread, "mainThread"); this.mainThread = Objects.requireNonNull(mainThread, "mainThread");
} }
@@ -102,6 +108,10 @@ public final class HydrologyPlanCache {
return windows.ready(Objects.requireNonNull(key, "key")); return windows.ready(Objects.requireNonNull(key, "key"));
} }
public long windowEvictions() {
return windows.evictions.get();
}
public RegionPlan region(RegionKey key) { public RegionPlan region(RegionKey key) {
Objects.requireNonNull(key, "key"); Objects.requireNonNull(key, "key");
requireOffMainThread("region " + key.rx() + "," + key.rz()); requireOffMainThread("region " + key.rx() + "," + key.rz());
@@ -179,11 +189,7 @@ public final class HydrologyPlanCache {
dropped.run(); dropped.run();
return; return;
} }
try { dispatch();
pool.execute(this::runNext);
} catch (RejectedExecutionException rejected) {
abandonQueue(rejected);
}
} }
private CompletableFuture<Void> prepare(int minChunkX, int minChunkZ, int maxChunkX, int maxChunkZ, int rings, Interest interest) { private CompletableFuture<Void> prepare(int minChunkX, int minChunkZ, int maxChunkX, int maxChunkZ, int rings, Interest interest) {
@@ -288,7 +294,17 @@ public final class HydrologyPlanCache {
} }
private long speculativePriority() { private long speculativePriority() {
return -(speculations.incrementAndGet() << SPECULATIVE_SHIFT); return Long.MAX_VALUE - (speculations.incrementAndGet() << SPECULATIVE_SHIFT);
}
private static int speculativeParallelism(ExecutorService pool) {
int parallelism = switch (pool) {
case MultiBurst burst -> burst.parallelism();
case ForkJoinPool forkJoin -> forkJoin.getParallelism();
case ThreadPoolExecutor threads -> threads.getCorePoolSize();
default -> 1;
};
return Math.max(1, parallelism / 2);
} }
private static int frontCells(int unit, int cell) { private static int frontCells(int unit, int cell) {
@@ -457,29 +473,34 @@ public final class HydrologyPlanCache {
slot.fail(new CancellationException("Hydrology plan cache is closed.")); slot.fail(new CancellationException("Hydrology plan cache is closed."));
return; return;
} }
try { dispatch();
pool.execute(this::runNext);
} catch (RejectedExecutionException rejected) {
abandonQueue(rejected);
}
} }
private void runNext() { private void runNext() {
Job job; Job job;
boolean speculative;
synchronized (lock) { synchronized (lock) {
job = queue.poll(); job = queue.peek();
if (job != null && job.slot != null) { if (job == null) {
return;
}
speculative = job.priority >= 0;
if (speculative && runningSpeculative >= speculativeParallelism) {
return;
}
queue.remove();
if (speculative) {
runningSpeculative++;
}
if (job.slot != null) {
job.slot.job = null; job.slot.job = null;
} }
} }
if (job == null) {
return;
}
if (closed) {
job.drop(new CancellationException("Hydrology plan cache is closed."));
return;
}
try { try {
if (closed) {
job.drop(new CancellationException("Hydrology plan cache is closed."));
return;
}
job.body.run(); job.body.run();
} catch (Throwable failure) { } catch (Throwable failure) {
if (job.slot != null) { if (job.slot != null) {
@@ -487,6 +508,25 @@ public final class HydrologyPlanCache {
} else if (!closed) { } else if (!closed) {
IrisLogging.reportError("A speculative hydrology task failed.", failure); IrisLogging.reportError("A speculative hydrology task failed.", failure);
} }
} finally {
if (speculative) {
boolean pending;
synchronized (lock) {
runningSpeculative--;
pending = !closed && !queue.isEmpty();
}
if (pending) {
dispatch();
}
}
}
}
private void dispatch() {
try {
pool.execute(this::runNext);
} catch (RejectedExecutionException rejected) {
abandonQueue(rejected);
} }
} }
@@ -513,8 +553,12 @@ public final class HydrologyPlanCache {
slot.priority = priority; slot.priority = priority;
Job job = slot.job; Job job = slot.job;
if (job != null && queue.remove(job)) { if (job != null && queue.remove(job)) {
boolean demanded = job.priority >= 0 && priority < 0;
job.priority = priority; job.priority = priority;
queue.add(job); queue.add(job);
if (demanded) {
dispatch();
}
} }
for (Slot<RegionKey, RegionPlan> need : slot.needs) { for (Slot<RegionKey, RegionPlan> need : slot.needs) {
promoteLocked(need, priority); promoteLocked(need, priority);
@@ -597,6 +641,7 @@ public final class HydrologyPlanCache {
private final class Shelf<K, V> { private final class Shelf<K, V> {
private final ConcurrentHashMap<K, Slot<K, V>> slots = new ConcurrentHashMap<>(); private final ConcurrentHashMap<K, Slot<K, V>> slots = new ConcurrentHashMap<>();
private final AtomicLong evictions = new AtomicLong();
private final int capacity; private final int capacity;
private final BiConsumer<K, Slot<K, V>> start; private final BiConsumer<K, Slot<K, V>> start;
@@ -658,7 +703,9 @@ public final class HydrologyPlanCache {
ready.sort(Comparator.comparingLong(Aged::used)); ready.sort(Comparator.comparingLong(Aged::used));
for (int index = 0; index < excess; index++) { for (int index = 0; index < excess; index++) {
Slot<K, V> slot = ready.get(index).slot(); Slot<K, V> slot = ready.get(index).slot();
slots.remove(slot.key, slot); if (slots.remove(slot.key, slot)) {
evictions.incrementAndGet();
}
} }
} }
} }
@@ -50,6 +50,7 @@ public final class HydrologySettings {
private final int minY; private final int minY;
private final int maxY; private final int maxY;
private final boolean enabled; private final boolean enabled;
private final float density;
private final Drainage drainage; private final Drainage drainage;
private final List<HydrologyTier> tiers; private final List<HydrologyTier> tiers;
private final Ponds ponds; private final Ponds ponds;
@@ -67,6 +68,10 @@ public final class HydrologySettings {
this.minY = minY; this.minY = minY;
this.maxY = maxY; this.maxY = maxY;
this.enabled = config.isEnabled(); this.enabled = config.isEnabled();
if (!Double.isFinite(config.getDensity()) || config.getDensity() < 0D || config.getDensity() > 1D) {
throw new IllegalArgumentException("Hydrology density must be within 0..1, got " + config.getDensity());
}
this.density = (float) config.getDensity();
this.drainage = drainage(config.getDrainage()); this.drainage = drainage(config.getDrainage());
this.tiers = tiers(config.getTiers()); this.tiers = tiers(config.getTiers());
this.ponds = ponds(config.getPonds()); this.ponds = ponds(config.getPonds());
@@ -126,6 +131,10 @@ public final class HydrologySettings {
return enabled; return enabled;
} }
public float density() {
return density;
}
public Drainage drainage() { public Drainage drainage() {
return drainage; return drainage;
} }
@@ -458,7 +467,7 @@ public final class HydrologySettings {
private long computeFingerprint() { private long computeFingerprint() {
Fingerprint hash = new Fingerprint().add(seed).add(seaLevel).add(minY).add(maxY).add(enabled) Fingerprint hash = new Fingerprint().add(seed).add(seaLevel).add(minY).add(maxY).add(enabled)
.add(prefetchRings).add(persist); .add(density).add(prefetchRings).add(persist);
addDrainage(hash); addDrainage(hash);
addTiers(hash); addTiers(hash);
addPonds(hash); addPonds(hash);
@@ -3,6 +3,7 @@ package art.arcane.iris.generation.hydrology.plan;
import art.arcane.iris.generation.hydrology.grid.Basin; import art.arcane.iris.generation.hydrology.grid.Basin;
import art.arcane.iris.generation.hydrology.grid.BasinExtractor; import art.arcane.iris.generation.hydrology.grid.BasinExtractor;
import art.arcane.iris.generation.hydrology.grid.Breacher; import art.arcane.iris.generation.hydrology.grid.Breacher;
import art.arcane.iris.generation.hydrology.grid.CellHash;
import art.arcane.iris.generation.hydrology.grid.ChannelHeads; import art.arcane.iris.generation.hydrology.grid.ChannelHeads;
import art.arcane.iris.generation.hydrology.grid.D8Router; import art.arcane.iris.generation.hydrology.grid.D8Router;
import art.arcane.iris.generation.hydrology.grid.DemGrid; import art.arcane.iris.generation.hydrology.grid.DemGrid;
@@ -28,6 +29,7 @@ public final class RegionPlanner {
private static final int SPAN = 3 * REGION_CELLS; private static final int SPAN = 3 * REGION_CELLS;
private static final float BASIN_DEPTH = 0.5f; private static final float BASIN_DEPTH = 0.5f;
private static final int BREACH_PASSES = 8; private static final int BREACH_PASSES = 8;
private static final long SALT_HEAD = 0x4d414a4f524844L;
private final HydrologySettings settings; private final HydrologySettings settings;
private final HeightLattice lattice; private final HeightLattice lattice;
@@ -127,7 +129,7 @@ public final class RegionPlanner {
float[] accumulation = FlowAccumulator.accumulate(receivers, filled, weights, settings.drainage().saturationCatchment()); float[] accumulation = FlowAccumulator.accumulate(receivers, filled, weights, settings.drainage().saturationCatchment());
BitSet candidates = ChannelHeads.candidates(surface, accumulation, samples.permission(), settings.majorTier().minCatchment(), settings.seaLevel()); BitSet candidates = ChannelHeads.candidates(surface, accumulation, samples.permission(), settings.majorTier().minCatchment(), settings.seaLevel());
BitSet channels = ChannelHeads.keep(surface, samples.height(), receivers, candidates, settings.drainage().headRelief(), cell -> false, BitSet channels = ChannelHeads.keep(surface, samples.height(), receivers, candidates, settings.drainage().headRelief(), cell -> false,
cell -> true); cell -> CellHash.unit(CellHash.mix(settings.seed(), SALT_HEAD, surface.blockX(cell), surface.blockZ(cell))) < settings.density());
return shaper.coarsePaths(surface, receivers, accumulation, channels, settings.seed()); return shaper.coarsePaths(surface, receivers, accumulation, channels, settings.seed());
} }
@@ -94,11 +94,18 @@ public final class WindowPlanner {
BitSet seeds = seeds(samples.ocean(), trunks); BitSet seeds = seeds(samples.ocean(), trunks);
DemGrid preliminary = new DemGrid(raw.originX(), raw.originZ(), CELL, SPAN, SPAN); DemGrid preliminary = new DemGrid(raw.originX(), raw.originZ(), CELL, SPAN, SPAN);
System.arraycopy(raw.values(), 0, preliminary.values(), 0, SPAN * SPAN); System.arraycopy(raw.values(), 0, preliminary.values(), 0, SPAN * SPAN);
BitSet channelHint = drain(preliminary, seeds, samples, trunks, lakes).channels(); Drainage initial = drain(preliminary, seeds, samples, trunks, lakes);
BitSet channelHint = (BitSet) initial.channels().clone();
channelHint.andNot(trunks.cells()); channelHint.andNot(trunks.cells());
CupSet cups = ponds.subtractCups(raw, key, samples, channelHint); CupSet cups = ponds.subtractCups(raw, key, samples, channelHint);
trunks.descend(raw.values()); Drainage drainage;
Drainage drainage = drain(raw, seeds, samples, trunks, lakes); if (cups.size() == 0) {
raw = preliminary;
drainage = initial;
} else {
trunks.descend(raw.values());
drainage = drain(raw, seeds, samples, trunks, lakes);
}
List<Pond> water = new ArrayList<>(ponds.ponds(raw, drainage.filled(), drainage.basins(), lakes, fine, cups)); List<Pond> water = new ArrayList<>(ponds.ponds(raw, drainage.filled(), drainage.basins(), lakes, fine, cups));
water.addAll(lakes); water.addAll(lakes);
ShapedNetwork network = shaper.shape(drainage.surface(), drainage.receivers(), drainage.accumulation(), drainage.channels(), water, ShapedNetwork network = shaper.shape(drainage.surface(), drainage.receivers(), drainage.accumulation(), drainage.channels(), water,
@@ -231,10 +238,8 @@ public final class WindowPlanner {
private boolean keepHead(DemGrid surface, int cell, float rainfall) { private boolean keepHead(DemGrid surface, int cell, float rainfall) {
HydrologySettings.Drainage drainage = settings.drainage(); HydrologySettings.Drainage drainage = settings.drainage();
if (!drainage.wetnessThinning()) { float keep = drainage.wetnessThinning() ? Math.clamp(Math.max(drainage.minimumHeadKeep(), rainfall), 0f, 1f) : 1f;
return true; keep *= settings.density();
}
float keep = Math.max(drainage.minimumHeadKeep(), rainfall);
return CellHash.unit(CellHash.mix(settings.seed(), SALT_HEAD, surface.blockX(cell), surface.blockZ(cell))) < keep; return CellHash.unit(CellHash.mix(settings.seed(), SALT_HEAD, surface.blockX(cell), surface.blockZ(cell))) < keep;
} }
@@ -28,13 +28,20 @@ final class MaskEdges {
int firstJ = Math.max(-margin, Math.floorDiv(minZ - mask.originZ() + half, step)); int firstJ = Math.max(-margin, Math.floorDiv(minZ - mask.originZ() + half, step));
int lastJ = Math.min(mask.depth() - 1 + margin, Math.floorDiv(maxZ - mask.originZ() + half, step)); int lastJ = Math.min(mask.depth() - 1 + margin, Math.floorDiv(maxZ - mask.originZ() + half, step));
for (int j = firstJ; j <= lastJ; j++) { for (int j = firstJ; j <= lastJ; j++) {
int start = Integer.MIN_VALUE;
for (int i = firstI; i <= lastI; i++) { for (int i = firstI; i <= lastI; i++) {
if (edge(mask, i, j)) { if (edge(mask, i, j)) {
int x = mask.originX() + i * step - half; if (start == Integer.MIN_VALUE) {
int z = mask.originZ() + j * step - half; start = i;
append(x, z, x + step - 1, z + step - 1); }
} else if (start != Integer.MIN_VALUE) {
appendRun(mask, start, i, j);
start = Integer.MIN_VALUE;
} }
} }
if (start != Integer.MIN_VALUE) {
appendRun(mask, start, lastI + 1, j);
}
} }
slots++; slots++;
starts[slots] = count; starts[slots] = count;
@@ -77,6 +84,14 @@ final class MaskEdges {
squares[count++] = maxZ; squares[count++] = maxZ;
} }
private void appendRun(PondMask mask, int first, int end, int row) {
int step = mask.step();
int half = step >> 1;
int x = mask.originX() + first * step - half;
int z = mask.originZ() + row * step - half;
append(x, z, mask.originX() + end * step - half - 1, z + step - 1);
}
enum Kind { enum Kind {
WATER, WATER,
BANK, BANK,
@@ -19,6 +19,7 @@ public final class CavePoolFiller {
private static final long SALT_COLUMN = 0x434156434f4c00L; private static final long SALT_COLUMN = 0x434156434f4c00L;
private static final long SALT_FLOOR = 0x4341564652L << 20; private static final long SALT_FLOOR = 0x4341564652L << 20;
private static final long SALT_FREEBOARD = 0x43415646524244L; private static final long SALT_FREEBOARD = 0x43415646524244L;
private static final long SALT_DENSITY = 0x43415644454e53L;
private static final HydrologyCaveCell GUARD = HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD); private static final HydrologyCaveCell GUARD = HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD);
private static final CaveVoxel[] VOXELS = CaveVoxel.values(); private static final CaveVoxel[] VOXELS = CaveVoxel.values();
private static final int[] DX = {1, -1, 0, 0, 0, 0}; private static final int[] DX = {1, -1, 0, 0, 0, 0};
@@ -31,18 +32,23 @@ public final class CavePoolFiller {
private final HydrologySettings.CavePools pools; private final HydrologySettings.CavePools pools;
private final Set<String> fluids; private final Set<String> fluids;
private final float density;
public CavePoolFiller(HydrologySettings settings) { public CavePoolFiller(HydrologySettings settings) {
this.pools = settings.underground().cavePools(); this.pools = settings.underground().cavePools();
this.fluids = settings.fluids().keySet(); this.fluids = settings.fluids().keySet();
this.density = settings.density();
} }
public void fill(int chunkX, int chunkZ, CarvedTerrain carved, MantleWriter writer, long seed) { public void fill(int chunkX, int chunkZ, CarvedTerrain carved, MantleWriter writer, long seed) {
if (!pools.enabled()) { if (!pools.enabled() || density == 0f) {
return; return;
} }
int attempts = attempts(seed, chunkX, chunkZ); int attempts = attempts(seed, chunkX, chunkZ);
for (int attempt = 0; attempt < attempts; attempt++) { for (int attempt = 0; attempt < attempts; attempt++) {
if (density < 1f && CellHash.unit(CellHash.mix(seed, SALT_DENSITY + attempt, chunkX, chunkZ)) >= density) {
continue;
}
long column = CellHash.mix(seed, SALT_COLUMN + attempt, chunkX, chunkZ); long column = CellHash.mix(seed, SALT_COLUMN + attempt, chunkX, chunkZ);
int x = (chunkX << 4) + (int) (column & 15); int x = (chunkX << 4) + (int) (column & 15);
int z = (chunkZ << 4) + (int) ((column >>> 4) & 15); int z = (chunkZ << 4) + (int) ((column >>> 4) & 15);
@@ -32,6 +32,7 @@ public final class SeaInletPlanner {
private static final long SALT_RADIUS = 0x53454152414449L; private static final long SALT_RADIUS = 0x53454152414449L;
private static final long SALT_HEADROOM = 0x53454148454144L; private static final long SALT_HEADROOM = 0x53454148454144L;
private static final long SALT_DEPTH = 0x53454144455054L; private static final long SALT_DEPTH = 0x53454144455054L;
private static final long SALT_DENSITY = 0x53454144454e53L;
private final HydrologySettings settings; private final HydrologySettings settings;
@@ -42,7 +43,7 @@ public final class SeaInletPlanner {
public List<SeaInlet> plan(DemGrid raw, HydrologySampleBuffer samples, int[] receivers, float[] accumulation, ShapedNetwork network, public List<SeaInlet> plan(DemGrid raw, HydrologySampleBuffer samples, int[] receivers, float[] accumulation, ShapedNetwork network,
long seed) { long seed) {
HydrologySettings.SeaInlets config = settings.underground().seaInlets(); HydrologySettings.SeaInlets config = settings.underground().seaInlets();
if (!config.enabled()) { if (!config.enabled() || settings.density() == 0f) {
return List.of(); return List.of();
} }
int count = raw.values().length; int count = raw.values().length;
@@ -101,6 +102,9 @@ public final class SeaInletPlanner {
private SeaInlet site(Terrain terrain, HydrologySettings.SeaInlets config, long key, List<Integer> candidates) { private SeaInlet site(Terrain terrain, HydrologySettings.SeaInlets config, long key, List<Integer> candidates) {
int siteX = (int) (key >> 32); int siteX = (int) (key >> 32);
int siteZ = (int) key; int siteZ = (int) key;
if (settings.density() < 1f && CellHash.unit(CellHash.mix(terrain.seed, SALT_DENSITY, siteX, siteZ)) >= settings.density()) {
return null;
}
double targetX = (siteX + CellHash.unit(CellHash.mix(terrain.seed, SALT_SITE_X, siteX, siteZ))) * config.spacing(); double targetX = (siteX + CellHash.unit(CellHash.mix(terrain.seed, SALT_SITE_X, siteX, siteZ))) * config.spacing();
double targetZ = (siteZ + CellHash.unit(CellHash.mix(terrain.seed, SALT_SITE_Z, siteX, siteZ))) * config.spacing(); double targetZ = (siteZ + CellHash.unit(CellHash.mix(terrain.seed, SALT_SITE_Z, siteX, siteZ))) * config.spacing();
DemGrid grid = terrain.grid; DemGrid grid = terrain.grid;
@@ -72,7 +72,8 @@ final class CupCarver {
} }
float slope = slope(raw, samples, cell); float slope = slope(raw, samples, cell);
if (suitable(samples, cell, slope) if (suitable(samples, cell, slope)
&& CellHash.unit(CellHash.mix(seed, SALT_SITE, siteX, siteZ)) < chance * samples.pondDensity()[cell]) { && CellHash.unit(CellHash.mix(seed, SALT_SITE, siteX, siteZ))
< Math.clamp(chance * samples.pondDensity()[cell], 0D, 1D) * settings.density()) {
addCup(raw, samples, cell, slope, siteX, siteZ, false, out); addCup(raw, samples, cell, slope, siteX, siteZ, false, out);
} }
} }
@@ -89,7 +90,7 @@ final class CupCarver {
} }
float slope = slope(raw, samples, cell); float slope = slope(raw, samples, cell);
double roll = CellHash.unit(CellHash.mix(settings.seed(), SALT_CHAIN, raw.blockX(cell), raw.blockZ(cell))); double roll = CellHash.unit(CellHash.mix(settings.seed(), SALT_CHAIN, raw.blockX(cell), raw.blockZ(cell)));
if (suitable(samples, cell, slope) && roll < chance * samples.pondDensity()[cell]) { if (suitable(samples, cell, slope) && roll < Math.clamp(chance * samples.pondDensity()[cell], 0D, 1D) * settings.density()) {
sites.add(new Site(roll, cell, slope)); sites.add(new Site(roll, cell, slope));
} }
} }
@@ -150,7 +150,7 @@ public final class PondPlanner {
} }
} }
return new Pond(pond.id(), pond.kind(), level, (int) Math.floor(lowest), pond.spillX(), pond.spillZ(), pond.spillHeight(), return new Pond(pond.id(), pond.kind(), level, (int) Math.floor(lowest), pond.spillX(), pond.spillZ(), pond.spillHeight(),
mask(box, wet), pond.outletReachId(), pond.damRaise(), pond.fluid(), pond.floorX(), pond.floorZ()); mask(box, ground, wet, level), pond.outletReachId(), pond.damRaise(), pond.fluid(), pond.floorX(), pond.floorZ());
} }
} }
return null; return null;
@@ -223,13 +223,16 @@ public final class PondPlanner {
return null; return null;
} }
if (kind == PondKind.NATURAL && CellHash.unit(CellHash.mix(settings.seed(), SALT_KEEP, floorX, floorZ)) if (kind == PondKind.NATURAL && CellHash.unit(CellHash.mix(settings.seed(), SALT_KEEP, floorX, floorZ))
>= settings.ponds().natural().keep() * floor.pondDensity()[0]) { >= Math.clamp(settings.ponds().natural().keep() * floor.pondDensity()[0], 0f, 1f) * settings.density()) {
return null; return null;
} }
return new Candidate(basin, kind, floorX, floorZ, floor.rainfall()[0], fluid(floor)); return new Candidate(basin, kind, floorX, floorZ, floor.rainfall()[0], fluid(floor));
} }
private Pond lake(LakeCandidate candidate, HeightLattice fine) { private Pond lake(LakeCandidate candidate, HeightLattice fine) {
if (CellHash.unit(CellHash.mix(settings.seed(), SALT_KEEP, candidate.floorX(), candidate.floorZ())) >= settings.density()) {
return null;
}
HydrologySampleBuffer floor = sampleAt(fine, candidate.floorX(), candidate.floorZ()); HydrologySampleBuffer floor = sampleAt(fine, candidate.floorX(), candidate.floorZ());
if ((floor.permission()[0] & HydrologySampleBuffer.ALLOW_PONDS) == 0) { if ((floor.permission()[0] & HydrologySampleBuffer.ALLOW_PONDS) == 0) {
return null; return null;
@@ -347,36 +350,31 @@ public final class PondPlanner {
level--; level--;
continue; continue;
} }
int limit = rim == null ? level : Math.min(level, rimGround(rim, box, wet) + settings.deformation().maxFill()); PondMask mask = mask(box, ground, wet, level);
int limit = rim == null ? level : Math.min(level, rimGround(rim, mask) + settings.deformation().maxFill());
if (limit >= level) { if (limit >= level) {
return pond(site, level, box, ground, wet, spill, damRaise); return pond(site, level, box, ground, wet, spill, damRaise, mask);
} }
level = limit; level = limit;
} }
return null; return null;
} }
private static int rimGround(HeightLattice lattice, Box box, boolean[] wet) { private static int rimGround(HeightLattice lattice, PondMask mask) {
int half = box.step() >> 1;
Set<Long> columns = new LinkedHashSet<>(); Set<Long> columns = new LinkedHashSet<>();
for (int node = 0; node < wet.length; node++) { for (int j = 0; j < mask.depth(); j++) {
if (!wet[node]) { for (int i = 0; i < mask.width(); i++) {
continue; if (!mask.node(i, j)) {
}
int i = node % box.width();
int j = node / box.width();
int x = box.x(node);
int z = box.z(node);
for (int axis = 0; axis < AXIS_X.length; axis++) {
int ni = i + AXIS_X[axis];
int nj = j + AXIS_Z[axis];
if (ni >= 0 && nj >= 0 && ni < box.width() && nj < box.depth() && wet[nj * box.width() + ni]) {
continue; continue;
} }
for (int along = -half; along < half; along++) { for (int axis = 0; axis < AXIS_X.length; axis++) {
int columnX = AXIS_X[axis] == 0 ? x + along : AXIS_X[axis] > 0 ? x + half : x - half - 1; int ni = i + AXIS_X[axis];
int columnZ = AXIS_Z[axis] == 0 ? z + along : AXIS_Z[axis] > 0 ? z + half : z - half - 1; int nj = j + AXIS_Z[axis];
columns.add(((long) columnX << 32) | (columnZ & 0xFFFFFFFFL)); if (!mask.node(ni, nj)) {
int x = mask.originX() + ni;
int z = mask.originZ() + nj;
columns.add(((long) x << 32) | (z & 0xFFFFFFFFL));
}
} }
} }
} }
@@ -397,7 +395,7 @@ public final class PondPlanner {
return xs.length == 0 ? Integer.MAX_VALUE : (int) Math.floor(lowest); return xs.length == 0 ? Integer.MAX_VALUE : (int) Math.floor(lowest);
} }
private Pond pond(Site site, int level, Box box, float[] ground, boolean[] wet, Spill spill, int damRaise) { private Pond pond(Site site, int level, Box box, float[] ground, boolean[] wet, Spill spill, int damRaise, PondMask mask) {
float lowest = Float.POSITIVE_INFINITY; float lowest = Float.POSITIVE_INFINITY;
for (int node = 0; node < wet.length; node++) { for (int node = 0; node < wet.length; node++) {
if (wet[node]) { if (wet[node]) {
@@ -406,7 +404,7 @@ public final class PondPlanner {
} }
long id = CellHash.mix(settings.seed(), salt(site.kind()), site.floorX(), site.floorZ()); long id = CellHash.mix(settings.seed(), salt(site.kind()), site.floorX(), site.floorZ());
return new Pond(id, site.kind(), level, (int) Math.floor(lowest), spill.x(), spill.z(), spill.height(), return new Pond(id, site.kind(), level, (int) Math.floor(lowest), spill.x(), spill.z(), spill.height(),
mask(box, wet), 0L, damRaise, site.fluid(), site.floorX(), site.floorZ()); mask, 0L, damRaise, site.fluid(), site.floorX(), site.floorZ());
} }
private static boolean covered(DemGrid raw, Basin basin, List<Pond> lakes) { private static boolean covered(DemGrid raw, Basin basin, List<Pond> lakes) {
@@ -681,7 +679,7 @@ public final class PondPlanner {
return true; return true;
} }
private static PondMask mask(Box box, boolean[] wet) { private static PondMask mask(Box box, float[] ground, boolean[] wet, int level) {
int minI = Integer.MAX_VALUE; int minI = Integer.MAX_VALUE;
int minJ = Integer.MAX_VALUE; int minJ = Integer.MAX_VALUE;
int maxI = Integer.MIN_VALUE; int maxI = Integer.MIN_VALUE;
@@ -698,6 +696,9 @@ public final class PondPlanner {
minJ = Math.max(0, minJ - 1); minJ = Math.max(0, minJ - 1);
maxI = Math.min(box.width() - 1, maxI + 1); maxI = Math.min(box.width() - 1, maxI + 1);
maxJ = Math.min(box.depth() - 1, maxJ + 1); maxJ = Math.min(box.depth() - 1, maxJ + 1);
if (box.step() < LAKE_STEP) {
return shorelineMask(box, ground, wet, level, minI, minJ, maxI, maxJ);
}
int width = maxI - minI + 1; int width = maxI - minI + 1;
int depth = maxJ - minJ + 1; int depth = maxJ - minJ + 1;
long[] bits = new long[(width * depth + 63) >>> 6]; long[] bits = new long[(width * depth + 63) >>> 6];
@@ -712,6 +713,46 @@ public final class PondPlanner {
return new PondMask(box.originX() + minI * box.step(), box.originZ() + minJ * box.step(), box.step(), width, depth, bits); return new PondMask(box.originX() + minI * box.step(), box.originZ() + minJ * box.step(), box.step(), width, depth, bits);
} }
private static PondMask shorelineMask(Box box, float[] ground, boolean[] wet, int level, int minI, int minJ, int maxI, int maxJ) {
int step = box.step();
int half = step >> 1;
int width = (maxI - minI + 1) * step;
int depth = (maxJ - minJ + 1) * step;
long[] bits = new long[(width * depth + 63) >>> 6];
for (int z = 0; z < depth; z++) {
int sampleZ = minJ * step + z - half;
int j = Math.floorDiv(sampleZ, step);
if (j < 0 || j + 1 >= box.depth()) {
continue;
}
float v = Math.floorMod(sampleZ, step) / (float) step;
for (int x = 0; x < width; x++) {
int sampleX = minI * step + x - half;
int i = Math.floorDiv(sampleX, step);
if (i < 0 || i + 1 >= box.width()) {
continue;
}
float u = Math.floorMod(sampleX, step) / (float) step;
int node = j * box.width() + i;
if (!wet[node] && !wet[node + 1] && !wet[node + box.width()] && !wet[node + box.width() + 1]) {
continue;
}
float first = lifted(box, ground, node) * (1f - u) + lifted(box, ground, node + 1) * u;
float second = lifted(box, ground, node + box.width()) * (1f - u)
+ lifted(box, ground, node + box.width() + 1) * u;
if (first * (1f - v) + second * v < level) {
int index = z * width + x;
bits[index >>> 6] |= 1L << index;
}
}
}
return new PondMask(box.originX() + minI * step - half, box.originZ() + minJ * step - half, 1, width, depth, bits);
}
private static float lifted(Box box, float[] ground, int node) {
return ground[node] - box.lift()[node];
}
private int freeboard(Site site) { private int freeboard(Site site) {
return CupCarver.pick(settings.ponds().freeboard(), return CupCarver.pick(settings.ponds().freeboard(),
CellHash.mix(settings.seed(), SALT_FREEBOARD, site.floorX(), site.floorZ())); CellHash.mix(settings.seed(), SALT_FREEBOARD, site.floorX(), site.floorZ()));
@@ -119,6 +119,17 @@ public final class DesktopUiMessages {
public static final TextKey PREGEN_EXISTING = TextKey.of("iris.desktop.pregen.existing", "Existing"); public static final TextKey PREGEN_EXISTING = TextKey.of("iris.desktop.pregen.existing", "Existing");
public static final TextKey PREGEN_NETWORK = TextKey.of("iris.desktop.pregen.network", "Network"); public static final TextKey PREGEN_NETWORK = TextKey.of("iris.desktop.pregen.network", "Network");
public static final TextKey PREGEN_TERRAIN_HINT = TextKey.of("iris.desktop.pregen.terrain_hint", "Finished chunks show terrain colors"); public static final TextKey PREGEN_TERRAIN_HINT = TextKey.of("iris.desktop.pregen.terrain_hint", "Finished chunks show terrain colors");
public static final TextKey PREGEN_GRID = TextKey.of("iris.desktop.pregen.grid", "Grid");
public static final TextKey PREGEN_GRID_HINT = TextKey.of("iris.desktop.pregen.grid_hint", "Show chunk and region borders (G)");
public static final TextKey PREGEN_FIT = TextKey.of("iris.desktop.pregen.fit", "Reset view");
public static final TextKey PREGEN_FIT_HINT = TextKey.of("iris.desktop.pregen.fit_hint", "Fit the whole generation area (R)");
public static final TextKey PREGEN_ZOOM_IN = TextKey.of("iris.desktop.pregen.zoom_in", "Zoom in (+)");
public static final TextKey PREGEN_ZOOM_OUT = TextKey.of("iris.desktop.pregen.zoom_out", "Zoom out (-)");
public static final TextKey PREGEN_ZOOM = TextKey.of("iris.desktop.pregen.zoom", "Zoom {zoom}");
public static final TextKey PREGEN_SHORTCUTS = TextKey.of("iris.desktop.pregen.shortcuts", "Shortcuts");
public static final TextKey PREGEN_SHORTCUTS_HINT = TextKey.of("iris.desktop.pregen.shortcuts_hint", "Show mouse and key shortcuts (/)");
public static final TextKey PREGEN_NAVIGATION_HINT = TextKey.of("iris.desktop.pregen.navigation_hint", "Drag to pan · Scroll to zoom · Hover to inspect chunks");
public static final TextKey PREGEN_SHORTCUT_DETAILS = TextKey.of("iris.desktop.pregen.shortcut_details", "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts");
public static final TextKey IMAGEMAP_TITLE = TextKey.of("iris.desktop.imagemap.title", "Image Map Studio"); public static final TextKey IMAGEMAP_TITLE = TextKey.of("iris.desktop.imagemap.title", "Image Map Studio");
public static final TextKey IMAGEMAP_PRESET = TextKey.of("iris.desktop.imagemap.preset", "Preset:"); public static final TextKey IMAGEMAP_PRESET = TextKey.of("iris.desktop.imagemap.preset", "Preset:");
public static final TextKey IMAGEMAP_LOAD = TextKey.of("iris.desktop.imagemap.load", "Load"); public static final TextKey IMAGEMAP_LOAD = TextKey.of("iris.desktop.imagemap.load", "Load");
@@ -216,7 +227,9 @@ public final class DesktopUiMessages {
PREGEN_METHOD, PREGEN_CACHED, PREGEN_FAILED, PREGEN_MAP, PREGEN_METHOD, PREGEN_CACHED, PREGEN_FAILED, PREGEN_MAP,
PREGEN_CHUNK_COORDINATES, PREGEN_WINDOW_HINT, PREGEN_CHUNK_COORDINATES, PREGEN_WINDOW_HINT,
PREGEN_BOUNDS, PREGEN_WAITING, PREGEN_READY, PREGEN_EXISTING, PREGEN_BOUNDS, PREGEN_WAITING, PREGEN_READY, PREGEN_EXISTING,
PREGEN_NETWORK, PREGEN_TERRAIN_HINT, PREGEN_NETWORK, PREGEN_TERRAIN_HINT, PREGEN_GRID, PREGEN_GRID_HINT,
PREGEN_FIT, PREGEN_FIT_HINT, PREGEN_ZOOM_IN, PREGEN_ZOOM_OUT, PREGEN_ZOOM,
PREGEN_SHORTCUTS, PREGEN_SHORTCUTS_HINT, PREGEN_NAVIGATION_HINT, PREGEN_SHORTCUT_DETAILS,
IMAGEMAP_TITLE, IMAGEMAP_PRESET, IMAGEMAP_LOAD, IMAGEMAP_IMPORT_PNG, IMAGEMAP_TITLE, IMAGEMAP_PRESET, IMAGEMAP_LOAD, IMAGEMAP_IMPORT_PNG,
IMAGEMAP_REPLACE_PNG, IMAGEMAP_PREVIEW, IMAGEMAP_EXPORT, IMAGEMAP_METADATA, IMAGEMAP_REPLACE_PNG, IMAGEMAP_PREVIEW, IMAGEMAP_EXPORT, IMAGEMAP_METADATA,
IMAGEMAP_RESOURCE, IMAGEMAP_COORDINATES, IMAGEMAP_BINDING_KEY, IMAGEMAP_MAP_KEY, IMAGEMAP_RESOURCE, IMAGEMAP_COORDINATES, IMAGEMAP_BINDING_KEY, IMAGEMAP_MAP_KEY,
@@ -388,10 +388,6 @@ public class StudioSVC implements IrisService {
fingerprint, fingerprint,
aliasPolicy aliasPolicy
); );
if (!fingerprint.equals(GenerationPackFingerprint.compute(
packRoot, GenerationPackFingerprint.CURRENT_VERSION))) {
throw new IOException("Generation pack changed during validation: " + packRoot);
}
return new GenerationCandidate(fingerprint, dimensionContract, registryContract); return new GenerationCandidate(fingerprint, dimensionContract, registryContract);
} finally { } finally {
data.close(); data.close();
@@ -29,7 +29,7 @@ import java.awt.event.MouseEvent;
* Lays an island over content that fills the whole area behind it. Dragging the island's right edge resizes it, * Lays an island over content that fills the whole area behind it. Dragging the island's right edge resizes it,
* always leaving the content some room beside it. * always leaving the content some room beside it.
*/ */
final class IslandOverlay extends JLayeredPane { public final class IslandOverlay extends JLayeredPane {
private static final long serialVersionUID = -4182209135360718342L; private static final long serialVersionUID = -4182209135360718342L;
private final IslandPanel island; private final IslandPanel island;
@@ -38,7 +38,7 @@ final class IslandOverlay extends JLayeredPane {
private final int minimumUncoveredWidth; private final int minimumUncoveredWidth;
private int islandWidth; private int islandWidth;
IslandOverlay(IslandPanel island, JComponent background, int islandWidth, int minimumIslandWidth, int minimumUncoveredWidth) { public IslandOverlay(IslandPanel island, JComponent background, int islandWidth, int minimumIslandWidth, int minimumUncoveredWidth) {
this.island = island; this.island = island;
this.background = background; this.background = background;
this.islandWidth = islandWidth; this.islandWidth = islandWidth;
@@ -34,13 +34,13 @@ import java.util.List;
* islands theme. The gap belongs to the content behind it, except for the strip along the right edge, which grabs the * islands theme. The gap belongs to the content behind it, except for the strip along the right edge, which grabs the
* island to resize it. * island to resize it.
*/ */
final class IslandPanel extends JPanel implements FloatingPanels.PartlySeeThrough { public final class IslandPanel extends JPanel implements FloatingPanels.PartlySeeThrough {
private static final long serialVersionUID = 7313084027614358519L; private static final long serialVersionUID = 7313084027614358519L;
private static final int GAP = 6; private static final int GAP = 6;
private static final int CORNER_ARC = 12; private static final int CORNER_ARC = 12;
private static final int INNER_PADDING = 4; private static final int INNER_PADDING = 4;
IslandPanel(JComponent content) { public IslandPanel(JComponent content) {
super(new BorderLayout()); super(new BorderLayout());
setOpaque(false); setOpaque(false);
int inset = GAP + INNER_PADDING; int inset = GAP + INNER_PADDING;
@@ -724,10 +724,13 @@ final class MapCanvas extends JPanel {
int tileHeight = Math.max(1, (int) Math.round(worldToScreenZ(worldZ + frameTileSpan)) - screenY); int tileHeight = Math.max(1, (int) Math.round(worldToScreenZ(worldZ + frameTileSpan)) - screenY);
if (tile.tile() != null) { if (tile.tile() != null) {
canvas.drawImage(tile.tile().image(style), screenX, screenY, tileWidth, tileHeight, null); canvas.drawImage(tile.tile().image(style), screenX, screenY, tileWidth, tileHeight, null);
continue; if (!tile.provisional()) {
continue;
}
} else {
canvas.setColor(((tile.tileX() ^ tile.tileZ()) & 1L) == 0L ? TILE_EMPTY_A : TILE_EMPTY_B);
canvas.fillRect(screenX, screenY, tileWidth, tileHeight);
} }
canvas.setColor(((tile.tileX() ^ tile.tileZ()) & 1L) == 0L ? TILE_EMPTY_A : TILE_EMPTY_B);
canvas.fillRect(screenX, screenY, tileWidth, tileHeight);
drawFallback(canvas, controller.fallback(frame, tile), screenX, screenY, tileWidth, tileHeight); drawFallback(canvas, controller.fallback(frame, tile), screenX, screenY, tileWidth, tileHeight);
} }
} }
@@ -46,7 +46,7 @@ public final class MapHost {
return GuiHost.isAvailable(); return GuiHost.isAvailable();
} }
static JFrame createFrame(String title) { public static JFrame createFrame(String title) {
XwaylandWindowManagerHint.apply(); XwaylandWindowManagerHint.apply();
StudioTheme.install(); StudioTheme.install();
JFrame frame = new JFrame(title); JFrame frame = new JFrame(title);
@@ -73,7 +73,7 @@ public final class MapHost {
} }
// A scaled-up default size can be bigger than a small or low resolution screen. // A scaled-up default size can be bigger than a small or low resolution screen.
static Dimension fitToScreen(Dimension preferred) { public static Dimension fitToScreen(Dimension preferred) {
Rectangle usable = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds(); Rectangle usable = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
return new Dimension(Math.min(preferred.width, usable.width), Math.min(preferred.height, usable.height)); return new Dimension(Math.min(preferred.width, usable.width), Math.min(preferred.height, usable.height));
} }
@@ -45,20 +45,20 @@ import java.util.concurrent.atomic.AtomicBoolean;
* The colours of IntelliJ's "New UI", light and dark, shared by FlatLaf's controls and everything the studio windows * The colours of IntelliJ's "New UI", light and dark, shared by FlatLaf's controls and everything the studio windows
* draw themselves. The theme follows the system unless the user picked light or dark. * draw themselves. The theme follows the system unless the user picked light or dark.
*/ */
final class StudioTheme { public final class StudioTheme {
static final Color EDITOR_BACKGROUND = new ThemeColor(0xFFFFFF, 0x1E1F22); public static final Color EDITOR_BACKGROUND = new ThemeColor(0xFFFFFF, 0x1E1F22);
static final Color PANEL_BACKGROUND = new ThemeColor(0xF7F8FA, 0x2B2D30); public static final Color PANEL_BACKGROUND = new ThemeColor(0xF7F8FA, 0x2B2D30);
static final Color HOVER_BACKGROUND = new ThemeColor(0xDFE1E5, 0x393B40); static final Color HOVER_BACKGROUND = new ThemeColor(0xDFE1E5, 0x393B40);
static final Color INACTIVE_SELECTION = new ThemeColor(0xDFE1E5, 0x43454A); static final Color INACTIVE_SELECTION = new ThemeColor(0xDFE1E5, 0x43454A);
static final Color SELECTION = new ThemeColor(0xD4E2FF, 0x2E436E); static final Color SELECTION = new ThemeColor(0xD4E2FF, 0x2E436E);
static final Color CONTROL_BORDER = new ThemeColor(0xC9CCD6, 0x4E5157); public static final Color CONTROL_BORDER = new ThemeColor(0xC9CCD6, 0x4E5157);
static final Color SEPARATOR = new ThemeColor(0xEBECF0, 0x1E1F22); static final Color SEPARATOR = new ThemeColor(0xEBECF0, 0x1E1F22);
static final Color TEXT = new ThemeColor(0x000000, 0xDFE1E5); public static final Color TEXT = new ThemeColor(0x000000, 0xDFE1E5);
static final ThemeColor SECONDARY_TEXT = new ThemeColor(0x6C707E, 0x9DA0A8); public static final ThemeColor SECONDARY_TEXT = new ThemeColor(0x6C707E, 0x9DA0A8);
static final Color MUTED_TEXT = new ThemeColor(0x818594, 0x6F737A); static final Color MUTED_TEXT = new ThemeColor(0x818594, 0x6F737A);
static final Color ACCENT = new Color(0x3574F0); public static final Color ACCENT = new Color(0x3574F0);
static final Color LINK = new ThemeColor(0x315FBD, 0x548AF7); static final Color LINK = new ThemeColor(0x315FBD, 0x548AF7);
static final Color ERROR = new ThemeColor(0xDB3B4B, 0xF75464); public static final Color ERROR = new ThemeColor(0xDB3B4B, 0xF75464);
static final Color WARNING = new ThemeColor(0xC27D04, 0xF2C55C); static final Color WARNING = new ThemeColor(0xC27D04, 0xF2C55C);
static final Color SUCCESS = new ThemeColor(0x208A3C, 0x5FB865); static final Color SUCCESS = new ThemeColor(0x208A3C, 0x5FB865);
static final Color OVERLAY_BACKGROUND = new ThemeColor(new Color(0xFF, 0xFF, 0xFF, 240), new Color(0x2B, 0x2D, 0x30, 236)); static final Color OVERLAY_BACKGROUND = new ThemeColor(new Color(0xFF, 0xFF, 0xFF, 240), new Color(0x2B, 0x2D, 0x30, 236));
@@ -231,7 +231,7 @@ final class StudioTheme {
/** /**
* FlatLaf grows fonts and its own controls to the screen's DPI; every size the studio sets itself has to grow with them. * FlatLaf grows fonts and its own controls to the screen's DPI; every size the studio sets itself has to grow with them.
*/ */
static int scale(int pixels) { public static int scale(int pixels) {
return Math.round(pixels * pixelScale); return Math.round(pixels * pixelScale);
} }
@@ -239,27 +239,27 @@ final class StudioTheme {
return pixels * pixelScale; return pixels * pixelScale;
} }
static Dimension scaledSize(int width, int height) { public static Dimension scaledSize(int width, int height) {
return new Dimension(scale(width), scale(height)); return new Dimension(scale(width), scale(height));
} }
static Border emptyBorder(int top, int left, int bottom, int right) { public static Border emptyBorder(int top, int left, int bottom, int right) {
return BorderFactory.createEmptyBorder(scale(top), scale(left), scale(bottom), scale(right)); return BorderFactory.createEmptyBorder(scale(top), scale(left), scale(bottom), scale(right));
} }
static Font font() { public static Font font() {
return themeFont("defaultFont", Font.PLAIN, 13); return themeFont("defaultFont", Font.PLAIN, 13);
} }
static Font smallFont() { public static Font smallFont() {
return themeFont("small.font", Font.PLAIN, 11); return themeFont("small.font", Font.PLAIN, 11);
} }
static Font boldFont() { public static Font boldFont() {
return font().deriveFont(Font.BOLD); return font().deriveFont(Font.BOLD);
} }
static Font titleFont() { public static Font titleFont() {
return font().deriveFont(Font.BOLD, font().getSize2D() + 1F); return font().deriveFont(Font.BOLD, font().getSize2D() + 1F);
} }
@@ -40,13 +40,13 @@ import java.awt.Insets;
/** /**
* Builds the IntelliJ style controls the studio windows share: a flat main toolbar, borderless toolbar buttons and search boxes. * Builds the IntelliJ style controls the studio windows share: a flat main toolbar, borderless toolbar buttons and search boxes.
*/ */
final class StudioToolbar { public final class StudioToolbar {
private static final int TOOLBAR_HEIGHT_PADDING = 5; private static final int TOOLBAR_HEIGHT_PADDING = 5;
private StudioToolbar() { private StudioToolbar() {
} }
static JToolBar create() { public static JToolBar create() {
JToolBar toolbar = new JToolBar(); JToolBar toolbar = new JToolBar();
toolbar.setFloatable(false); toolbar.setFloatable(false);
toolbar.setRollover(true); toolbar.setRollover(true);
@@ -65,7 +65,7 @@ final class StudioToolbar {
return label; return label;
} }
static JButton button(String text, String tooltip, Runnable action) { public static JButton button(String text, String tooltip, Runnable action) {
JButton button = toolbarButton(text, tooltip); JButton button = toolbarButton(text, tooltip);
button.addActionListener(event -> action.run()); button.addActionListener(event -> action.run());
return button; return button;
@@ -74,7 +74,7 @@ final class StudioToolbar {
/** /**
* Opens a menu to pick light, dark or the system's theme. * Opens a menu to pick light, dark or the system's theme.
*/ */
static JButton themeButton() { public static JButton themeButton() {
JButton button = toolbarButton("Theme", "Light, dark, or whatever the system uses"); JButton button = toolbarButton("Theme", "Light, dark, or whatever the system uses");
button.addActionListener(event -> themeMenu().show(button, 0, button.getHeight())); button.addActionListener(event -> themeMenu().show(button, 0, button.getHeight()));
return button; return button;
@@ -102,7 +102,7 @@ final class StudioToolbar {
return button; return button;
} }
static JToggleButton toggle(String text, String tooltip) { public static JToggleButton toggle(String text, String tooltip) {
JToggleButton toggle = new JToggleButton(text); JToggleButton toggle = new JToggleButton(text);
toggle.setToolTipText(tooltip); toggle.setToolTipText(tooltip);
toggle.setFocusable(false); toggle.setFocusable(false);
@@ -134,7 +134,7 @@ final class StudioToolbar {
return panel; return panel;
} }
static JScrollPane scroll(JComponent content) { public static JScrollPane scroll(JComponent content) {
JScrollPane scrollPane = new JScrollPane(content); JScrollPane scrollPane = new JScrollPane(content);
scrollPane.setBorder(BorderFactory.createEmptyBorder()); scrollPane.setBorder(BorderFactory.createEmptyBorder());
scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
@@ -578,6 +578,10 @@ final class VisionRenderController implements AutoCloseable {
return tile; return tile;
} }
boolean provisional() {
return provisional;
}
private void setTile(MapTile tile) { private void setTile(MapTile tile) {
this.tile = tile; this.tile = tile;
provisional = false; provisional = false;
@@ -20,6 +20,7 @@ package art.arcane.iris.studio.map.components;
import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.terrain.InferredType; import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimension;
@@ -166,7 +167,15 @@ public final class MapTileRenderer {
} }
private double[] sampleHeights(TileGrid grid) { private double[] sampleHeights(TileGrid grid) {
return new HeightLattice(grid, engine.getComplex().getHeightStream()).interpolatedHeights(); IrisComplex complex = engine.getComplex();
IrisDimension dimension = engine.getDimension();
HydrologyRuntime water = complex.getHydrologyRuntime();
if (water == null || !water.enabled() || dimension != null && dimension.getTerrainTransform() != null) {
return new HeightLattice(grid, complex.getHeightStream()).interpolatedHeights();
}
try (HydrologyRuntime.PreviewScope ignored = water.preview()) {
return new HeightLattice(grid, complex.getHeightStream()).interpolatedHeights();
}
} }
// Large uniform squares are filled after a few samples; squares stay small enough to not skip tiny biomes. // Large uniform squares are filled after a few samples; squares stay small enough to not skip tiny biomes.
@@ -90,6 +90,7 @@ final class WaterMap {
lakes.add(lake.id()); lakes.add(lake.id());
} }
Map<Long, ChunkWaterRaster> rasters = new HashMap<>(); Map<Long, ChunkWaterRaster> rasters = new HashMap<>();
WaterMap planned = null;
for (int pixelZ = 0; pixelZ < resolution; pixelZ++) { for (int pixelZ = 0; pixelZ < resolution; pixelZ++) {
checkCancelled(); checkCancelled();
int minimumZ = (int) Math.floor(startZ + pixelZ * step); int minimumZ = (int) Math.floor(startZ + pixelZ * step);
@@ -102,6 +103,11 @@ final class WaterMap {
ChunkWaterRaster raster = raster(rasters, x >> 4, z >> 4); ChunkWaterRaster raster = raster(rasters, x >> 4, z >> 4);
if (raster.provisional()) { if (raster.provisional()) {
provisional = true; provisional = true;
if (planned == null) {
planned = new WaterMap(runtime, resolution, startX, startZ, step, cancelled);
planned.drawPlans(plans);
}
stamp(pixelX, pixelZ, planned.at(pixelX, pixelZ));
} else if (!raster.empty()) { } else if (!raster.empty()) {
stamp(pixelX, pixelZ, columnType(raster, x & 15, z & 15, lakes)); stamp(pixelX, pixelZ, columnType(raster, x & 15, z & 15, lakes));
} }
@@ -2,32 +2,60 @@ package art.arcane.iris.studio.view;
import art.arcane.iris.localization.DesktopUiMessages; import art.arcane.iris.localization.DesktopUiMessages;
import art.arcane.iris.localization.IrisLanguage; import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.studio.map.StudioTheme;
import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.MessageArgument;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.SwingUtilities;
import javax.swing.ToolTipManager; import javax.swing.ToolTipManager;
import java.awt.Color; import java.awt.Color;
import java.awt.Cursor;
import java.awt.FontMetrics;
import java.awt.Graphics; import java.awt.Graphics;
import java.awt.Graphics2D; import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle; import java.awt.Rectangle;
import java.awt.RenderingHints; import java.awt.RenderingHints;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent; import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.awt.geom.AffineTransform;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt; import java.awt.image.DataBufferInt;
final class PregenMapPanel extends JPanel { final class PregenMapPanel extends JPanel {
private static final double MIN_ZOOM = 0.25D;
private static final double MAX_ZOOM = 128D;
private static final double ZOOM_STEP = 1.25D;
private final PregenRenderSnapshot.Bounds bounds; private final PregenRenderSnapshot.Bounds bounds;
private final PregenMapState state; private final PregenMapState state;
private final PregenMapState.View view; private final PregenMapState.View view;
private final BufferedImage image; private final BufferedImage image;
private final String[] shortcuts;
private volatile boolean disposed; private volatile boolean disposed;
private Runnable viewListener;
private Point drag;
private Point hover;
private double centerX = 0.5D;
private double centerZ = 0.5D;
private double zoom = 1D;
private int coveredLeftWidth;
private boolean gridVisible;
private boolean helpVisible;
PregenMapPanel(PregenMapState state) { PregenMapPanel(PregenMapState state) {
this.state = state; this.state = state;
bounds = state.bounds(); bounds = state.bounds();
image = new BufferedImage(state.width(), state.height(), BufferedImage.TYPE_INT_RGB); image = new BufferedImage(state.width(), state.height(), BufferedImage.TYPE_INT_RGB);
image.setAccelerationPriority(0F);
view = state.attach(((DataBufferInt) image.getRaster().getDataBuffer()).getData()); view = state.attach(((DataBufferInt) image.getRaster().getDataBuffer()).getData());
setBackground(PregenRenderer.BACKGROUND); shortcuts = IrisLanguage.plain(DesktopUiMessages.PREGEN_SHORTCUT_DETAILS).split("\n");
setBackground(StudioTheme.EDITOR_BACKGROUND);
setCursor(Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR));
setFocusable(true);
installNavigation();
ToolTipManager.sharedInstance().registerComponent(this); ToolTipManager.sharedInstance().registerComponent(this);
} }
@@ -43,23 +71,83 @@ final class PregenMapPanel extends JPanel {
void disposeMap() { void disposeMap() {
disposed = true; disposed = true;
viewListener = null;
ToolTipManager.sharedInstance().unregisterComponent(this); ToolTipManager.sharedInstance().unregisterComponent(this);
view.close(); view.close();
} }
void setViewListener(Runnable listener) {
viewListener = listener;
}
void setCoveredLeftWidth(int width) {
coveredLeftWidth = Math.max(0, width);
changed();
}
void resetView() {
centerX = 0.5D;
centerZ = 0.5D;
zoom = 1D;
changed();
}
double zoom() {
return zoom;
}
void zoomIn() {
zoomAt(ZOOM_STEP, (getWidth() + coveredLeftWidth) / 2D, getHeight() / 2D);
}
void zoomOut() {
zoomAt(1D / ZOOM_STEP, (getWidth() + coveredLeftWidth) / 2D, getHeight() / 2D);
}
void zoomAt(double factor, double anchorX, double anchorY) {
Rectangle2D.Double before = mapArea();
double mapX = (anchorX - before.x) / before.width;
double mapZ = (anchorY - before.y) / before.height;
zoom = Math.clamp(zoom * factor, MIN_ZOOM, MAX_ZOOM);
Rectangle2D.Double after = mapArea();
centerX += mapX - (anchorX - after.x) / after.width;
centerZ += mapZ - (anchorY - after.y) / after.height;
changed();
}
void panBy(double pixelsX, double pixelsY) {
Rectangle2D.Double area = mapArea();
centerX -= pixelsX / area.width;
centerZ -= pixelsY / area.height;
changed();
}
void toggleGrid() {
gridVisible = !gridVisible;
repaint();
}
boolean isGridVisible() {
return gridVisible;
}
void toggleHelp() {
helpVisible = !helpVisible;
repaint();
}
void dismissHelp() {
helpVisible = false;
repaint();
}
String hoverCoordinates() {
return hover == null ? null : coordinatesAt(hover.x, hover.y);
}
@Override @Override
public String getToolTipText(MouseEvent event) { public String getToolTipText(MouseEvent event) {
if (disposed) { return coordinatesAt(event.getX(), event.getY());
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 @Override
@@ -67,11 +155,20 @@ final class PregenMapPanel extends JPanel {
super.paintComponent(graphics); super.paintComponent(graphics);
Graphics2D canvas = (Graphics2D) graphics.create(); Graphics2D canvas = (Graphics2D) graphics.create();
try { try {
Rectangle area = fit(bounds, getWidth(), getHeight()); Rectangle2D.Double area = mapArea();
canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
canvas.drawImage(image, area.x, area.y, area.width, area.height, null); AffineTransform transform = AffineTransform.getTranslateInstance(area.x, area.y);
canvas.setColor(PregenRenderer.BORDER); transform.scale(area.width / image.getWidth(), area.height / image.getHeight());
canvas.drawRect(area.x, area.y, area.width - 1, area.height - 1); canvas.drawImage(image, transform, null);
if (gridVisible) {
paintGrid(canvas, area);
}
canvas.setColor(StudioTheme.CONTROL_BORDER);
canvas.draw(area);
paintHover(canvas, area);
if (helpVisible) {
paintHelp(canvas);
}
} finally { } finally {
canvas.dispose(); canvas.dispose();
} }
@@ -85,4 +182,150 @@ final class PregenMapPanel extends JPanel {
int mapHeight = Math.max(1, (int) Math.floor(bounds.height() * scale)); int mapHeight = Math.max(1, (int) Math.floor(bounds.height() * scale));
return new Rectangle((width - mapWidth) / 2, (height - mapHeight) / 2, mapWidth, mapHeight); return new Rectangle((width - mapWidth) / 2, (height - mapHeight) / 2, mapWidth, mapHeight);
} }
private Rectangle2D.Double mapArea() {
Rectangle fit = fit(bounds, Math.max(1, getWidth() - coveredLeftWidth), getHeight());
double width = fit.width * zoom;
double height = fit.height * zoom;
return new Rectangle2D.Double(coveredLeftWidth + fit.getCenterX() - centerX * width,
fit.getCenterY() - centerZ * height, width, height);
}
private String coordinatesAt(int x, int y) {
if (disposed || x < coveredLeftWidth) {
return null;
}
Rectangle2D.Double area = mapArea();
if (!area.contains(x, y)) {
return null;
}
long chunkX = bounds.minX() + (long) Math.floor((x - area.x) * bounds.width() / area.width);
long chunkZ = bounds.minZ() + (long) Math.floor((y - area.y) * bounds.height() / area.height);
return IrisLanguage.plain(DesktopUiMessages.PREGEN_CHUNK_COORDINATES,
MessageArgument.trusted("x", chunkX), MessageArgument.trusted("z", chunkZ));
}
private void installNavigation() {
MouseAdapter navigation = new MouseAdapter() {
@Override
public void mousePressed(MouseEvent event) {
if (SwingUtilities.isLeftMouseButton(event)) {
requestFocusInWindow();
drag = event.getPoint();
setCursor(Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR));
}
}
@Override
public void mouseReleased(MouseEvent event) {
drag = null;
setCursor(Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR));
}
@Override
public void mouseDragged(MouseEvent event) {
if (drag != null) {
hover = event.getPoint();
panBy(event.getX() - drag.x, event.getY() - drag.y);
drag = hover;
}
}
@Override
public void mouseMoved(MouseEvent event) {
hover = event.getPoint();
changed();
}
@Override
public void mouseExited(MouseEvent event) {
hover = null;
changed();
}
@Override
public void mouseWheelMoved(MouseWheelEvent event) {
zoomAt(Math.pow(ZOOM_STEP, -event.getPreciseWheelRotation()), event.getX(), event.getY());
}
};
addMouseListener(navigation);
addMouseMotionListener(navigation);
addMouseWheelListener(navigation);
}
private void paintGrid(Graphics2D canvas, Rectangle2D.Double area) {
if (!area.intersects(0, 0, getWidth(), getHeight())) {
return;
}
double chunkWidth = area.width / bounds.width();
double chunkHeight = area.height / bounds.height();
long step = Math.min(chunkWidth, chunkHeight) >= 24D ? 1 : 32;
while (step * Math.min(chunkWidth, chunkHeight) < 24D) {
step *= 2;
}
canvas.setColor(StudioTheme.CONTROL_BORDER);
long minX = Math.max(bounds.minX(), (long) Math.floor(bounds.minX() - area.x / chunkWidth));
long maxX = Math.min((long) bounds.maxX() + 1, (long) Math.ceil(bounds.minX() + (getWidth() - area.x) / chunkWidth));
long minZ = Math.max(bounds.minZ(), (long) Math.floor(bounds.minZ() - area.y / chunkHeight));
long maxZ = Math.min((long) bounds.maxZ() + 1, (long) Math.ceil(bounds.minZ() + (getHeight() - area.y) / chunkHeight));
int top = (int) Math.max(0, area.y);
int bottom = (int) Math.min(getHeight(), area.getMaxY());
int left = (int) Math.max(0, area.x);
int right = (int) Math.min(getWidth(), area.getMaxX());
for (long x = Math.ceilDiv(minX, step) * step; x <= maxX; x += step) {
int screenX = (int) Math.round(area.x + (x - bounds.minX()) * chunkWidth);
canvas.drawLine(screenX, top, screenX, bottom);
}
for (long z = Math.ceilDiv(minZ, step) * step; z <= maxZ; z += step) {
int screenZ = (int) Math.round(area.y + (z - bounds.minZ()) * chunkHeight);
canvas.drawLine(left, screenZ, right, screenZ);
}
}
private void paintHover(Graphics2D canvas, Rectangle2D.Double area) {
if (hover == null || hover.x < coveredLeftWidth || !area.contains(hover)) {
return;
}
double chunkWidth = area.width / bounds.width();
double chunkHeight = area.height / bounds.height();
if (Math.min(chunkWidth, chunkHeight) < 4D) {
return;
}
double x = area.x + Math.floor((hover.x - area.x) / chunkWidth) * chunkWidth;
double y = area.y + Math.floor((hover.y - area.y) / chunkHeight) * chunkHeight;
canvas.setColor(StudioTheme.ACCENT);
canvas.draw(new Rectangle2D.Double(x, y, chunkWidth, chunkHeight));
}
private void paintHelp(Graphics2D canvas) {
canvas.setFont(StudioTheme.font());
FontMetrics metrics = canvas.getFontMetrics();
int padding = StudioTheme.scale(14);
int width = 0;
for (String line : shortcuts) {
width = Math.max(width, metrics.stringWidth(line));
}
width += padding * 2;
int height = metrics.getHeight() * shortcuts.length + padding * 2;
int x = Math.max(coveredLeftWidth + padding, getWidth() - width - padding);
int arc = StudioTheme.scale(10);
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
canvas.setColor(StudioTheme.PANEL_BACKGROUND);
canvas.fillRoundRect(x, padding, width, height, arc, arc);
canvas.setColor(StudioTheme.CONTROL_BORDER);
canvas.drawRoundRect(x, padding, width, height, arc, arc);
canvas.setColor(StudioTheme.TEXT);
int baseline = padding * 2 + metrics.getAscent();
for (String line : shortcuts) {
canvas.drawString(line, x + padding, baseline);
baseline += metrics.getHeight();
}
}
private void changed() {
if (viewListener != null) {
viewListener.run();
}
repaint();
}
} }
@@ -10,7 +10,7 @@ import java.util.concurrent.locks.ReentrantLock;
public final class PregenMapState { public final class PregenMapState {
private static final int MAX_RASTER_SIZE = 1024; private static final int MAX_RASTER_SIZE = 1024;
static final Color WAITING = new Color(29, 35, 46); static final Color WAITING = new Color(57, 59, 64);
private final PregenRenderSnapshot.Bounds bounds; private final PregenRenderSnapshot.Bounds bounds;
private final int width; private final int width;
@@ -22,13 +22,18 @@ import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.localization.DesktopUiMessages; import art.arcane.iris.localization.DesktopUiMessages;
import art.arcane.iris.localization.IrisLanguage; import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.studio.map.IslandOverlay;
import art.arcane.iris.studio.map.IslandPanel;
import art.arcane.iris.studio.map.MapHost;
import art.arcane.iris.studio.map.StudioTheme;
import art.arcane.iris.studio.map.StudioToolbar;
import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.format.Form;
import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.volmlib.util.localization.TextKey; import art.arcane.volmlib.util.localization.TextKey;
import javax.swing.AbstractAction; import javax.swing.AbstractAction;
import javax.swing.AbstractButton;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JComponent; import javax.swing.JComponent;
import javax.swing.JFrame; import javax.swing.JFrame;
@@ -36,30 +41,31 @@ import javax.swing.JLabel;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JProgressBar; import javax.swing.JProgressBar;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import javax.swing.Scrollable;
import javax.swing.JTextArea; import javax.swing.JTextArea;
import javax.swing.JToggleButton;
import javax.swing.JToolBar;
import javax.swing.KeyStroke; import javax.swing.KeyStroke;
import javax.swing.Scrollable;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import javax.swing.Timer; import javax.swing.Timer;
import javax.swing.plaf.basic.BasicButtonUI; import javax.swing.text.DefaultCaret;
import javax.swing.plaf.basic.BasicGraphicsUtils; import javax.swing.text.JTextComponent;
import javax.swing.plaf.basic.BasicProgressBarUI;
import javax.swing.plaf.basic.BasicScrollBarUI;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Color; import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension; import java.awt.Dimension;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Font; import java.awt.Font;
import java.awt.Frame; import java.awt.Frame;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.GridLayout; import java.awt.GridLayout;
import java.awt.Graphics; import java.awt.Insets;
import java.awt.KeyboardFocusManager;
import java.awt.LayoutManager; import java.awt.LayoutManager;
import java.awt.Rectangle; import java.awt.Rectangle;
import java.awt.event.ActionEvent; import java.awt.event.ActionEvent;
import java.awt.event.FocusAdapter; import java.awt.event.ComponentAdapter;
import java.awt.event.FocusEvent; import java.awt.event.ComponentEvent;
import java.awt.event.KeyEvent; import java.awt.event.KeyEvent;
import java.awt.event.WindowAdapter; import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent; import java.awt.event.WindowEvent;
@@ -67,43 +73,43 @@ import java.util.Objects;
public final class PregenRenderer extends JPanel { public final class PregenRenderer extends JPanel {
private static final long serialVersionUID = 2094606939770332040L; private static final long serialVersionUID = 2094606939770332040L;
static final Color BACKGROUND = new Color(12, 15, 22); private static final Color BACKGROUND = StudioTheme.EDITOR_BACKGROUND;
static final Color BORDER = new Color(105, 119, 139); private static final Color BORDER = StudioTheme.CONTROL_BORDER;
private static final Color SURFACE = new Color(17, 20, 29); private static final Color SURFACE = StudioTheme.PANEL_BACKGROUND;
private static final Color FIELD = new Color(29, 34, 47); private static final Color TEXT = StudioTheme.TEXT;
private static final Color TEXT = new Color(230, 234, 242); private static final Color MUTED = StudioTheme.SECONDARY_TEXT;
private static final Color MUTED = new Color(163, 173, 191); private static final Color ACCENT = StudioTheme.ACCENT;
private static final Color ACCENT = new Color(101, 194, 149); private static final Color ERROR = StudioTheme.ERROR;
private static final Color ERROR = new Color(255, 139, 139);
static final Color GENERATING = new Color(204, 168, 107); static final Color GENERATING = new Color(204, 168, 107);
static final Color GENERATED = ACCENT; static final Color GENERATED = new Color(101, 194, 149);
static final Color EXISTS = new Color(93, 133, 112); static final Color EXISTS = new Color(93, 133, 112);
static final Color NETWORK = new Color(157, 138, 176); static final Color NETWORK = new Color(157, 138, 176);
static final Color NETWORK_GENERATING = new Color(129, 112, 145); static final Color NETWORK_GENERATING = new Color(129, 112, 145);
private static final Font BODY_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 13);
private static final Font LABEL_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 11);
private static final Font VALUE_FONT = new Font(Font.SANS_SERIF, Font.BOLD, 15);
private final PregenRenderSource source; private final PregenRenderSource source;
private final Runnable onPause; private final Runnable onPause;
private final PregenMapPanel map; private final PregenMapPanel map;
private final Timer repaintTimer; private final Timer repaintTimer;
private final JLabel worldLabel = label("", new Font(Font.SANS_SERIF, Font.BOLD, 21), TEXT); private final JLabel worldLabel = label("", StudioTheme.titleFont(), TEXT);
private final JLabel phaseLabel = label("", BODY_FONT, MUTED); private final JLabel phaseLabel = label("", StudioTheme.font(), MUTED);
private final JLabel countLabel = label("", BODY_FONT, TEXT); private final JLabel countLabel = label("", StudioTheme.font(), TEXT);
private final JLabel percentLabel = label("", new Font(Font.SANS_SERIF, Font.BOLD, 24), TEXT); private final JLabel percentLabel = label("", StudioTheme.titleFont(), TEXT);
private final JLabel currentValue = label("", VALUE_FONT, TEXT); private final JLabel currentValue = label("", StudioTheme.boldFont(), TEXT);
private final JLabel overallValue = label("", VALUE_FONT, TEXT); private final JLabel overallValue = label("", StudioTheme.boldFont(), TEXT);
private final JLabel thirtyValue = label("", VALUE_FONT, TEXT); private final JLabel thirtyValue = label("", StudioTheme.boldFont(), TEXT);
private final JLabel sixtyValue = label("", VALUE_FONT, TEXT); private final JLabel sixtyValue = label("", StudioTheme.boldFont(), TEXT);
private final JLabel etaValue = label("", VALUE_FONT, TEXT); private final JLabel etaValue = label("", StudioTheme.boldFont(), TEXT);
private final JLabel elapsedValue = label("", VALUE_FONT, TEXT); private final JLabel elapsedValue = label("", StudioTheme.boldFont(), TEXT);
private final JLabel memoryValue = label("", VALUE_FONT, TEXT); private final JLabel memoryValue = label("", StudioTheme.boldFont(), TEXT);
private final JLabel pressureValue = label("", VALUE_FONT, TEXT); private final JLabel pressureValue = label("", StudioTheme.boldFont(), TEXT);
private final JLabel methodLabel = label("", LABEL_FONT, MUTED); private final JLabel methodLabel = label("", StudioTheme.smallFont(), MUTED);
private final JTextArea failureLabel = wrappedText("", BODY_FONT, ERROR, SURFACE); private final JTextArea failureLabel = wrappedText("", StudioTheme.font(), ERROR, SURFACE);
private String controlFailure; private String controlFailure;
private final JButton pauseButton = new JButton(); private final JButton pauseButton = StudioToolbar.button("", "", this::togglePause);
private final JToggleButton gridToggle = StudioToolbar.toggle(text(DesktopUiMessages.PREGEN_GRID),
text(DesktopUiMessages.PREGEN_GRID_HINT));
private final JLabel coordinatesLabel = label("", StudioTheme.smallFont(), MUTED);
private final JLabel zoomLabel = label("", StudioTheme.smallFont(), MUTED);
private final JProgressBar progressBar = new JProgressBar(0, 10_000); private final JProgressBar progressBar = new JProgressBar(0, 10_000);
private volatile boolean renderingEnabled; private volatile boolean renderingEnabled;
private volatile boolean disposed; private volatile boolean disposed;
@@ -116,16 +122,23 @@ public final class PregenRenderer extends JPanel {
failureLabel.setFocusable(true); failureLabel.setFocusable(true);
PregenRenderSnapshot initial = source.renderSnapshot(); PregenRenderSnapshot initial = source.renderSnapshot();
map = new PregenMapPanel(source.renderMapState()); map = new PregenMapPanel(source.renderMapState());
setLayout(new BorderLayout(0, 18)); setLayout(new BorderLayout());
setBackground(BACKGROUND); setBackground(BACKGROUND);
setBorder(BorderFactory.createEmptyBorder(20, 24, 18, 24)); map.getAccessibleContext().setAccessibleName(text(DesktopUiMessages.PREGEN_MAP));
add(header(), BorderLayout.NORTH); map.setViewListener(this::updateMapStatus);
JPanel content = panel(new BorderLayout(18, 0), BACKGROUND); IslandPanel sidebar = new IslandPanel(statusArea(initial.bounds()));
content.add(mapArea(initial.bounds()), BorderLayout.CENTER); sidebar.addComponentListener(new ComponentAdapter() {
content.add(statusArea(), BorderLayout.EAST); @Override
add(content, BorderLayout.CENTER); public void componentResized(ComponentEvent event) {
add(wrappedText(text(DesktopUiMessages.PREGEN_WINDOW_HINT), LABEL_FONT, MUTED, BACKGROUND), BorderLayout.SOUTH); map.setCoveredLeftWidth(sidebar.getWidth());
installPauseControl(); }
});
add(toolbar(), BorderLayout.NORTH);
add(new IslandOverlay(sidebar, map, StudioTheme.scale(300), StudioTheme.scale(260),
StudioTheme.scale(300)), BorderLayout.CENTER);
add(statusBar(), BorderLayout.SOUTH);
installControls();
updateMapStatus();
repaintTimer = new Timer(IrisSettings.get().getGui().isMaximumPregenGuiFPS() ? 4 : 250, repaintTimer = new Timer(IrisSettings.get().getGui().isMaximumPregenGuiFPS() ? 4 : 250,
event -> refresh()); event -> refresh());
refresh(); refresh();
@@ -135,14 +148,14 @@ public final class PregenRenderer extends JPanel {
if (!SwingUtilities.isEventDispatchThread()) { if (!SwingUtilities.isEventDispatchThread()) {
throw new IllegalStateException("The pregeneration window must open on the Swing event thread"); throw new IllegalStateException("The pregeneration window must open on the Swing event thread");
} }
JFrame frame = MapHost.createFrame(title);
PregenRenderer renderer = new PregenRenderer(source, onPause); PregenRenderer renderer = new PregenRenderer(source, onPause);
JFrame frame = new JFrame(title);
GuiHost.prepareFrame(frame);
renderer.frame = frame; renderer.frame = frame;
renderer.renderingEnabled = true; renderer.renderingEnabled = true;
frame.setContentPane(renderer); frame.setContentPane(renderer);
frame.setSize(1040, 760); frame.setSize(MapHost.fitToScreen(StudioTheme.scaledSize(1240, 800)));
frame.setMinimumSize(new Dimension(760, 620)); frame.setMinimumSize(StudioTheme.scaledSize(820, 520));
frame.setLocationRelativeTo(null);
frame.addWindowListener(new WindowAdapter() { frame.addWindowListener(new WindowAdapter() {
@Override @Override
public void windowClosed(WindowEvent event) { public void windowClosed(WindowEvent event) {
@@ -172,67 +185,80 @@ public final class PregenRenderer extends JPanel {
} }
} }
private JPanel header() { private JToolBar toolbar() {
JPanel heading = panel(new BorderLayout(16, 0), BACKGROUND); JToolBar toolbar = StudioToolbar.create();
JPanel identity = panel(new GridLayout(2, 1, 0, 4), BACKGROUND); worldLabel.setBorder(StudioTheme.emptyBorder(0, 6, 0, 14));
identity.add(worldLabel); worldLabel.setMaximumSize(StudioTheme.scaledSize(220, 36));
identity.add(phaseLabel); worldLabel.setMinimumSize(StudioTheme.scaledSize(80, 24));
heading.add(identity, BorderLayout.CENTER); toolbar.add(worldLabel);
heading.add(pauseButton, BorderLayout.EAST); toolbar.add(pauseButton);
JPanel counts = panel(new BorderLayout(), BACKGROUND); toolbar.addSeparator();
counts.add(countLabel, BorderLayout.WEST); toolbar.add(StudioToolbar.button(text(DesktopUiMessages.PREGEN_FIT),
counts.add(percentLabel, BorderLayout.EAST); text(DesktopUiMessages.PREGEN_FIT_HINT), map::resetView));
progressBar.setUI(new BasicProgressBarUI()); toolbar.add(StudioToolbar.button("−", text(DesktopUiMessages.PREGEN_ZOOM_OUT), map::zoomOut));
progressBar.setForeground(ACCENT); toolbar.add(StudioToolbar.button("+", text(DesktopUiMessages.PREGEN_ZOOM_IN), map::zoomIn));
progressBar.setBackground(FIELD); gridToggle.addActionListener(event -> toggleGrid());
progressBar.setBorderPainted(false); toolbar.add(gridToggle);
progressBar.setPreferredSize(new Dimension(1, 6)); toolbar.add(Box.createHorizontalGlue());
progressBar.setFocusable(false); toolbar.add(StudioToolbar.themeButton());
JPanel progress = panel(new BorderLayout(0, 9), BACKGROUND); toolbar.add(StudioToolbar.button(text(DesktopUiMessages.PREGEN_SHORTCUTS),
progress.add(counts, BorderLayout.NORTH); text(DesktopUiMessages.PREGEN_SHORTCUTS_HINT), map::toggleHelp));
progress.add(progressBar, BorderLayout.SOUTH); for (Component control : toolbar.getComponents()) {
JPanel header = panel(new BorderLayout(0, 18), BACKGROUND); if (control instanceof JButton || control instanceof JToggleButton) {
header.add(heading, BorderLayout.NORTH); control.setFocusable(true);
header.add(progress, BorderLayout.SOUTH); }
return header; }
return toolbar;
} }
private JPanel mapArea(PregenRenderSnapshot.Bounds bounds) { private JPanel statusBar() {
JPanel area = panel(new BorderLayout(0, 8), BACKGROUND); JPanel status = panel(new BorderLayout(12, 0), SURFACE);
map.getAccessibleContext().setAccessibleName(text(DesktopUiMessages.PREGEN_MAP)); status.setBorder(StudioTheme.emptyBorder(5, 12, 5, 12));
area.add(map, BorderLayout.CENTER); status.add(coordinatesLabel, BorderLayout.CENTER);
JLabel coordinates = label(text(DesktopUiMessages.PREGEN_BOUNDS, status.add(zoomLabel, BorderLayout.EAST);
MessageArgument.trusted("minX", Form.f(bounds.minX())), status.setToolTipText(text(DesktopUiMessages.PREGEN_WINDOW_HINT));
MessageArgument.trusted("maxX", Form.f(bounds.maxX())), return status;
MessageArgument.trusted("minZ", Form.f(bounds.minZ())),
MessageArgument.trusted("maxZ", Form.f(bounds.maxZ()))), LABEL_FONT, MUTED);
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 JScrollPane statusArea() { private void updateMapStatus() {
String coordinates = map.hoverCoordinates();
coordinatesLabel.setText(coordinates == null ? text(DesktopUiMessages.PREGEN_NAVIGATION_HINT) : coordinates);
zoomLabel.setText(text(DesktopUiMessages.PREGEN_ZOOM,
MessageArgument.trusted("zoom", Form.pc(map.zoom(), 0))));
}
private JScrollPane statusArea(PregenRenderSnapshot.Bounds bounds) {
JPanel status = new StatusPanel(); JPanel status = new StatusPanel();
status.setBackground(SURFACE); status.setBackground(SURFACE);
status.setBorder(BorderFactory.createEmptyBorder(18, 16, 16, 16)); status.setBorder(StudioTheme.emptyBorder(14, 12, 12, 12));
GridBagConstraints constraints = new GridBagConstraints(); GridBagConstraints constraints = new GridBagConstraints();
constraints.gridx = 0; constraints.gridx = 0;
constraints.gridy = 0; constraints.gridy = 0;
constraints.weightx = 1; constraints.weightx = 1;
constraints.fill = GridBagConstraints.HORIZONTAL; constraints.fill = GridBagConstraints.HORIZONTAL;
constraints.anchor = GridBagConstraints.NORTHWEST; constraints.anchor = GridBagConstraints.NORTHWEST;
constraints.insets = new Insets(0, 0, 18, 0); constraints.insets = new Insets(0, 0, StudioTheme.scale(12), 0);
status.add(label(text(DesktopUiMessages.PREGEN_TITLE), StudioTheme.titleFont(), TEXT), constraints);
constraints.gridy++;
status.add(phaseLabel, constraints);
constraints.gridy++;
JPanel progress = panel(new BorderLayout(0, StudioTheme.scale(8)), SURFACE);
progress.add(percentLabel, BorderLayout.NORTH);
progressBar.setForeground(ACCENT);
progressBar.setBackground(BACKGROUND);
progressBar.setBorderPainted(false);
progressBar.setPreferredSize(StudioTheme.scaledSize(1, 4));
progressBar.setFocusable(false);
progress.add(progressBar, BorderLayout.CENTER);
progress.add(countLabel, BorderLayout.SOUTH);
status.add(progress, constraints);
constraints.gridy++;
status.add(failureLabel, constraints); status.add(failureLabel, constraints);
constraints.gridy++; constraints.gridy++;
currentValue.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 25)); status.add(metricRow(DesktopUiMessages.PREGEN_CURRENT, currentValue), constraints);
etaValue.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 23));
status.add(metric(DesktopUiMessages.PREGEN_CURRENT, currentValue), constraints);
constraints.gridy++; constraints.gridy++;
status.add(metric(DesktopUiMessages.PREGEN_ETA, etaValue), constraints); status.add(metricRow(DesktopUiMessages.PREGEN_ETA, etaValue), constraints);
constraints.gridy++; constraints.gridy++;
constraints.insets = new Insets(0, 0, 10, 0);
status.add(metricRow(DesktopUiMessages.PREGEN_OVERALL, overallValue), constraints); status.add(metricRow(DesktopUiMessages.PREGEN_OVERALL, overallValue), constraints);
constraints.gridy++; constraints.gridy++;
status.add(metricRow(DesktopUiMessages.PREGEN_THIRTY, thirtyValue), constraints); status.add(metricRow(DesktopUiMessages.PREGEN_THIRTY, thirtyValue), constraints);
@@ -247,47 +273,26 @@ public final class PregenRenderer extends JPanel {
constraints.gridy++; constraints.gridy++;
status.add(methodLabel, constraints); status.add(methodLabel, constraints);
constraints.gridy++; constraints.gridy++;
status.add(legend(), constraints);
constraints.gridy++;
status.add(wrappedText(text(DesktopUiMessages.PREGEN_BOUNDS,
MessageArgument.trusted("minX", Form.f(bounds.minX())),
MessageArgument.trusted("maxX", Form.f(bounds.maxX())),
MessageArgument.trusted("minZ", Form.f(bounds.minZ())),
MessageArgument.trusted("maxZ", Form.f(bounds.maxZ()))), StudioTheme.smallFont(), MUTED, SURFACE), constraints);
constraints.gridy++;
status.add(wrappedText(text(DesktopUiMessages.PREGEN_WINDOW_HINT), StudioTheme.smallFont(), MUTED, SURFACE), constraints);
constraints.gridy++;
constraints.weighty = 1; constraints.weighty = 1;
status.add(panel(new BorderLayout(), SURFACE), constraints); status.add(panel(new BorderLayout(), SURFACE), constraints);
JScrollPane scroll = new JScrollPane(status); JScrollPane scroll = StudioToolbar.scroll(status);
scroll.setBorder(BorderFactory.createLineBorder(BORDER));
scroll.getViewport().setBackground(SURFACE); 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; return scroll;
} }
private JPanel legend() { private JPanel legend() {
JPanel legend = panel(new GridLayout(0, 2, 12, 7), BACKGROUND); JPanel legend = panel(new GridLayout(0, 2, StudioTheme.scale(12), StudioTheme.scale(7)), SURFACE);
legend.setBorder(StudioTheme.emptyBorder(8, 0, 4, 0));
legend.add(legendItem(DesktopUiMessages.PREGEN_WAITING, PregenMapState.WAITING)); legend.add(legendItem(DesktopUiMessages.PREGEN_WAITING, PregenMapState.WAITING));
legend.add(legendItem(DesktopUiMessages.PREGEN_GENERATING, GENERATING)); legend.add(legendItem(DesktopUiMessages.PREGEN_GENERATING, GENERATING));
legend.add(legendItem(DesktopUiMessages.PREGEN_READY, GENERATED)); legend.add(legendItem(DesktopUiMessages.PREGEN_READY, GENERATED));
@@ -298,64 +303,27 @@ public final class PregenRenderer extends JPanel {
} }
private JPanel legendItem(TextKey key, Color color) { private JPanel legendItem(TextKey key, Color color) {
JPanel item = panel(new BorderLayout(7, 0), BACKGROUND); JPanel item = panel(new BorderLayout(StudioTheme.scale(7), 0), SURFACE);
JPanel swatch = panel(new BorderLayout(), color); JPanel swatch = panel(new BorderLayout(), color);
swatch.setPreferredSize(new Dimension(10, 10)); swatch.setPreferredSize(StudioTheme.scaledSize(10, 10));
swatch.setBorder(BorderFactory.createLineBorder(BORDER)); swatch.setBorder(BorderFactory.createLineBorder(BORDER));
JPanel marker = panel(new BorderLayout(), BACKGROUND); JPanel marker = panel(new BorderLayout(), SURFACE);
marker.add(swatch, BorderLayout.NORTH); marker.add(swatch, BorderLayout.NORTH);
item.add(marker, BorderLayout.WEST); item.add(marker, BorderLayout.WEST);
item.add(wrappedText(text(key), LABEL_FONT, MUTED, BACKGROUND), BorderLayout.CENTER); item.add(wrappedText(text(key), StudioTheme.smallFont(), MUTED, SURFACE), BorderLayout.CENTER);
return item; return item;
} }
private JPanel metric(TextKey title, JLabel value) {
JPanel metric = panel(new BorderLayout(0, 5), SURFACE);
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) { private JPanel metricRow(TextKey title, JLabel value) {
JPanel row = panel(new BorderLayout(8, 0), SURFACE); JPanel row = panel(new BorderLayout(StudioTheme.scale(8), 0), SURFACE);
value.setFont(BODY_FONT); row.add(wrappedText(text(title), StudioTheme.smallFont(), MUTED, SURFACE), BorderLayout.CENTER);
row.add(wrappedText(text(title), LABEL_FONT, MUTED, SURFACE), BorderLayout.CENTER);
row.add(value, BorderLayout.EAST); row.add(value, BorderLayout.EAST);
return row; return row;
} }
private void installPauseControl() { private void installControls() {
pauseButton.setUI(new BasicButtonUI() { pauseButton.setFocusable(true);
@Override
protected void paintText(Graphics graphics, AbstractButton button, Rectangle bounds, String text) {
if (button.isEnabled()) {
super.paintText(graphics, button, bounds, text);
return;
}
graphics.setColor(MUTED);
BasicGraphicsUtils.drawStringUnderlineCharAt(graphics, text, button.getDisplayedMnemonicIndex(),
bounds.x, bounds.y + graphics.getFontMetrics().getAscent());
}
});
pauseButton.setBackground(FIELD);
pauseButton.setForeground(TEXT);
pauseButton.setFont(BODY_FONT);
pauseButton.putClientProperty("html.disable", Boolean.TRUE); pauseButton.putClientProperty("html.disable", Boolean.TRUE);
pauseButton.setFocusPainted(false);
pauseButton.setPreferredSize(new Dimension(108, 46));
setPauseBorder(false);
pauseButton.addFocusListener(new FocusAdapter() {
@Override
public void focusGained(FocusEvent event) {
setPauseBorder(true);
}
@Override
public void focusLost(FocusEvent event) {
setPauseBorder(false);
}
});
pauseButton.addActionListener(event -> togglePause());
pauseButton.getInputMap(JComponent.WHEN_FOCUSED).put( pauseButton.getInputMap(JComponent.WHEN_FOCUSED).put(
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "activatePause"); KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "activatePause");
pauseButton.getActionMap().put("activatePause", new AbstractAction() { pauseButton.getActionMap().put("activatePause", new AbstractAction() {
@@ -364,19 +332,39 @@ public final class PregenRenderer extends JPanel {
pauseButton.doClick(); pauseButton.doClick();
} }
}); });
getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_P, 0, true), "pause"); bind(KeyStroke.getKeyStroke(KeyEvent.VK_P, 0, true), "pause", this::togglePause);
getActionMap().put("pause", new AbstractAction() { bind(KeyStroke.getKeyStroke(KeyEvent.VK_R, 0), "reset-view", map::resetView);
bind(KeyStroke.getKeyStroke(KeyEvent.VK_BACK_SLASH, 0), "reset-zoom", map::resetView);
bind(KeyStroke.getKeyStroke(KeyEvent.VK_EQUALS, 0), "zoom-in", map::zoomIn);
bind(KeyStroke.getKeyStroke(KeyEvent.VK_EQUALS, KeyEvent.SHIFT_DOWN_MASK), "zoom-in-plus", map::zoomIn);
bind(KeyStroke.getKeyStroke(KeyEvent.VK_ADD, 0), "zoom-in-keypad", map::zoomIn);
bind(KeyStroke.getKeyStroke(KeyEvent.VK_MINUS, 0), "zoom-out", map::zoomOut);
bind(KeyStroke.getKeyStroke(KeyEvent.VK_SUBTRACT, 0), "zoom-out-keypad", map::zoomOut);
bind(KeyStroke.getKeyStroke(KeyEvent.VK_G, 0), "grid", this::toggleGrid);
bind(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0), "pan-left", () -> map.panBy(64, 0));
bind(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0), "pan-right", () -> map.panBy(-64, 0));
bind(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), "pan-up", () -> map.panBy(0, 64));
bind(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), "pan-down", () -> map.panBy(0, -64));
bind(KeyStroke.getKeyStroke(KeyEvent.VK_SLASH, 0), "help", map::toggleHelp);
bind(KeyStroke.getKeyStroke(KeyEvent.VK_SLASH, KeyEvent.SHIFT_DOWN_MASK), "help-question", map::toggleHelp);
bind(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "dismiss", map::dismissHelp);
}
private void bind(KeyStroke key, String name, Runnable action) {
getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(key, name);
getActionMap().put(name, new AbstractAction() {
@Override @Override
public void actionPerformed(ActionEvent event) { public void actionPerformed(ActionEvent event) {
togglePause(); if (!(KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner() instanceof JTextComponent)) {
action.run();
}
} }
}); });
} }
private void setPauseBorder(boolean focused) { private void toggleGrid() {
pauseButton.setBorder(BorderFactory.createCompoundBorder( map.toggleGrid();
BorderFactory.createLineBorder(focused ? ACCENT : BORDER, 2), gridToggle.setSelected(map.isGridVisible());
BorderFactory.createEmptyBorder(8, 14, 8, 14)));
} }
private void togglePause() { private void togglePause() {
@@ -512,7 +500,10 @@ public final class PregenRenderer extends JPanel {
} }
private static JTextArea wrappedText(String text, Font font, Color foreground, Color background) { private static JTextArea wrappedText(String text, Font font, Color foreground, Color background) {
JTextArea area = new JTextArea(text); JTextArea area = new JTextArea();
DefaultCaret caret = (DefaultCaret) area.getCaret();
caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE);
area.setText(text);
area.setEditable(false); area.setEditable(false);
area.setFocusable(false); area.setFocusable(false);
area.setLineWrap(true); area.setLineWrap(true);
@@ -158,13 +158,6 @@ public final class GenerationRegistryContractFactory {
CustomBiomeAliasPolicy requiredAliasPolicy = Objects.requireNonNull(aliasPolicy, "aliasPolicy"); CustomBiomeAliasPolicy requiredAliasPolicy = Objects.requireNonNull(aliasPolicy, "aliasPolicy");
requireRuntimeIdentity(requiredGenerationRegistry); requireRuntimeIdentity(requiredGenerationRegistry);
Path packRoot = requiredData.getDataFolder().toPath().toAbsolutePath().normalize(); Path packRoot = requiredData.getDataFolder().toPath().toAbsolutePath().normalize();
String actualPackFingerprint = GenerationPackFingerprint.compute(
packRoot,
GenerationPackFingerprint.CURRENT_VERSION
);
if (!requiredPackFingerprint.equals(actualPackFingerprint)) {
throw new IOException("Iris generation registry capture rejected a changed pack.");
}
Map<GenerationRegistryContract.PhysicalResourceKey, PlatformGenerationRegistry.Definition> definitions = Map<GenerationRegistryContract.PhysicalResourceKey, PlatformGenerationRegistry.Definition> definitions =
new LinkedHashMap<>(); new LinkedHashMap<>();
@@ -221,7 +214,15 @@ public final class GenerationRegistryContractFactory {
ReferencedPlatformKeys referenced = referencedPlatformKeys(packRoot, requiredRegistries); ReferencedPlatformKeys referenced = referencedPlatformKeys(packRoot, requiredRegistries);
addPlatformDefinitions(definitions, BLOCK_REGISTRY, referenced.blockKeys(), requiredGenerationRegistry); addPlatformDefinitions(definitions, BLOCK_REGISTRY, referenced.blockKeys(), requiredGenerationRegistry);
addPlatformDefinitions(definitions, ENTITY_TYPE_REGISTRY, referenced.entityKeys(), requiredGenerationRegistry); addPlatformDefinitions(definitions, ENTITY_TYPE_REGISTRY, referenced.entityKeys(), requiredGenerationRegistry);
return contract(definitions, generatedSources, requiredGenerationRegistry).withBiomeTags(biomeTags); GenerationRegistryContract captured = contract(definitions, generatedSources, requiredGenerationRegistry).withBiomeTags(biomeTags);
String actualPackFingerprint = GenerationPackFingerprint.compute(
packRoot,
GenerationPackFingerprint.CURRENT_VERSION
);
if (!requiredPackFingerprint.equals(actualPackFingerprint)) {
throw new IOException("Iris generation registry capture rejected a changed pack.");
}
return captured;
} }
public static CustomBiomeDefinition customBiomeDefinition( public static CustomBiomeDefinition customBiomeDefinition(
@@ -1937,6 +1937,17 @@ ready = "Bereit"
existing = "Vorhanden" existing = "Vorhanden"
network = "Netzwerk" network = "Netzwerk"
terrain_hint = "Fertige Chunks zeigen Geländefarben" terrain_hint = "Fertige Chunks zeigen Geländefarben"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1937,6 +1937,17 @@ ready = "Listo"
existing = "Existente" existing = "Existente"
network = "Red" network = "Red"
terrain_hint = "Los chunks terminados muestran los colores del terreno" terrain_hint = "Los chunks terminados muestran los colores del terreno"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1942,6 +1942,17 @@ ready = "Valmis"
existing = "Olemassa" existing = "Olemassa"
network = "Verkko" network = "Verkko"
terrain_hint = "Valmiit lohkot näyttävät maaston värit" terrain_hint = "Valmiit lohkot näyttävät maaston värit"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1937,6 +1937,17 @@ ready = "Prêt"
existing = "Existant" existing = "Existant"
network = "Réseau" network = "Réseau"
terrain_hint = "Les chunks terminés affichent les couleurs du terrain" terrain_hint = "Les chunks terminés affichent les couleurs du terrain"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1942,6 +1942,17 @@ ready = "מוכן"
existing = "קיים" existing = "קיים"
network = "רשת" network = "רשת"
terrain_hint = "צ׳אנקים שהושלמו מציגים את צבעי השטח" terrain_hint = "צ׳אנקים שהושלמו מציגים את צבעי השטח"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1937,6 +1937,17 @@ ready = "Pronto"
existing = "Esistente" existing = "Esistente"
network = "Rete" network = "Rete"
terrain_hint = "I chunk completati mostrano i colori del terreno" terrain_hint = "I chunk completati mostrano i colori del terreno"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1940,6 +1940,17 @@ ready = "準備完了"
existing = "既存" existing = "既存"
network = "ネットワーク" network = "ネットワーク"
terrain_hint = "完了したチャンクは地形の色で表示されます" terrain_hint = "完了したチャンクは地形の色で表示されます"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1940,6 +1940,17 @@ ready = "준비됨"
existing = "기존" existing = "기존"
network = "네트워크" network = "네트워크"
terrain_hint = "완료된 청크는 지형 색상으로 표시됩니다" terrain_hint = "완료된 청크는 지형 색상으로 표시됩니다"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1942,6 +1942,17 @@ ready = "Paruošta"
existing = "Esamos" existing = "Esamos"
network = "Tinklas" network = "Tinklas"
terrain_hint = "Baigtos sritys rodo reljefo spalvas" terrain_hint = "Baigtos sritys rodo reljefo spalvas"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1937,6 +1937,17 @@ ready = "Gereed"
existing = "Bestaand" existing = "Bestaand"
network = "Netwerk" network = "Netwerk"
terrain_hint = "Voltooide chunks tonen terreinkleuren" terrain_hint = "Voltooide chunks tonen terreinkleuren"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1942,6 +1942,17 @@ ready = "Gotowe"
existing = "Istniejące" existing = "Istniejące"
network = "Sieć" network = "Sieć"
terrain_hint = "Gotowe chunki pokazują kolory terenu" terrain_hint = "Gotowe chunki pokazują kolory terenu"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1937,6 +1937,17 @@ ready = "Pronto"
existing = "Existente" existing = "Existente"
network = "Rede" network = "Rede"
terrain_hint = "Os chunks concluídos mostram as cores do terreno" terrain_hint = "Os chunks concluídos mostram as cores do terreno"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1942,6 +1942,17 @@ ready = "Готово"
existing = "Существующие" existing = "Существующие"
network = "Сеть" network = "Сеть"
terrain_hint = "Готовые чанки отображают цвета ландшафта" terrain_hint = "Готовые чанки отображают цвета ландшафта"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1942,6 +1942,17 @@ ready = "Hazır"
existing = "Mevcut" existing = "Mevcut"
network = "Ağ" network = "Ağ"
terrain_hint = "Tamamlanan chunklar arazi renklerini gösterir" terrain_hint = "Tamamlanan chunklar arazi renklerini gösterir"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1940,6 +1940,17 @@ ready = "Sẵn sàng"
existing = "Có sẵn" existing = "Có sẵn"
network = "Mạng" network = "Mạng"
terrain_hint = "Chunk hoàn tất hiển thị màu địa hình" terrain_hint = "Chunk hoàn tất hiển thị màu địa hình"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1940,6 +1940,17 @@ ready = "就绪"
existing = "已有" existing = "已有"
network = "网络" network = "网络"
terrain_hint = "已完成的区块显示地形颜色" terrain_hint = "已完成的区块显示地形颜色"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -1940,6 +1940,17 @@ ready = "就緒"
existing = "既有" existing = "既有"
network = "網路" network = "網路"
terrain_hint = "已完成的區塊顯示地形顏色" terrain_hint = "已完成的區塊顯示地形顏色"
grid = "Grid"
grid_hint = "Show chunk and region borders (G)"
fit = "Reset view"
fit_hint = "Fit the whole generation area (R)"
zoom_in = "Zoom in (+)"
zoom_out = "Zoom out (-)"
zoom = "Zoom {zoom}"
shortcuts = "Shortcuts"
shortcuts_hint = "Show mouse and key shortcuts (/)"
navigation_hint = "Drag to pan · Scroll to zoom · Hover to inspect chunks"
shortcut_details = "Drag or arrow keys: pan\nScroll or + / -: zoom\nR or \\: fit generation area\nG: chunk and region grid\nP: pause or resume generation\n/ or Escape: close shortcuts"
[iris.runtime.what] [iris.runtime.what]
material = "Material: {material}" material = "Material: {material}"
@@ -53,6 +53,7 @@ import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.concurrent.AbstractExecutorService; import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.CancellationException; import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@@ -79,8 +80,10 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
@@ -296,6 +299,31 @@ public class HydrologyRuntimeTest {
} }
} }
@Test(timeout = 180_000L)
public void chunksSharePrefetchAndRetryAfterEvictionOrFailure() {
FakePlanners planners = new FakePlanners(settings());
HydrologyPlanCache cache = spy(new HydrologyPlanCache(planners.settings, planners::region, planners::window,
pool(planners, 2), () -> false));
AtomicInteger prefetches = new AtomicInteger();
doAnswer(invocation -> prefetches.incrementAndGet() == 2
? CompletableFuture.failedFuture(new IllegalStateException("prefetch failed")) : CompletableFuture.completedFuture(null))
.when(cache).prepareWindowsAround(anyInt(), anyInt(), anyInt(), anyInt(), anyInt());
AtomicLong evictions = new AtomicLong();
doAnswer(invocation -> evictions.get()).when(cache).windowEvictions();
HydrologyRuntime runtime = new HydrologyRuntime(planners.settings, cache, planners::natural, () -> false, planners.ticker::get);
runtimes.add(runtime);
runtime.raster(0, 0);
runtime.raster(1, 0);
assertEquals(1, prefetches.get());
evictions.incrementAndGet();
runtime.raster(2, 0);
assertEquals(2, prefetches.get());
runtime.raster(3, 0);
assertEquals(3, prefetches.get());
runtime.raster(4, 0);
assertEquals(3, prefetches.get());
}
@Test(timeout = 180_000L) @Test(timeout = 180_000L)
public void preparePregenerationPlansRegionsInOrder() throws Exception { public void preparePregenerationPlansRegionsInOrder() throws Exception {
FakePlanners planners = new FakePlanners(settings()); FakePlanners planners = new FakePlanners(settings());
@@ -702,6 +730,44 @@ public class HydrologyRuntimeTest {
verify(engine, never()).getComplex(); verify(engine, never()).getComplex();
} }
@Test
public void zeroDensitySkipsRuntimePlanningAfterValidatingSettings() {
Engine engine = mock(Engine.class);
IrisComplex complex = mock(IrisComplex.class);
IrisDimension dimension = new IrisDimension();
dimension.setHydrology(new IrisHydrology().setEnabled(true).setDensity(0D));
when(engine.getDimension()).thenReturn(dimension);
when(engine.getSeedManager()).thenReturn(new SeedManager(1337L));
when(engine.getHeight()).thenReturn(384);
assertDisabled(HydrologyRuntime.create(engine, complex));
verifyNoInteractions(complex);
verify(engine, never()).getPlatformHooks();
dimension.getHydrology().setDensity(-1D);
assertThrows(IllegalArgumentException.class, () -> HydrologyRuntime.create(engine, complex));
}
@Test(timeout = 15_000L)
public void nestedPreviewScopesStayNonblockingAndRestoreDemandSampling() throws Exception {
try (FakeHydrology water = new FakeHydrology(() -> false, (x, z) -> 60, 40, 63)) {
HydrologyRuntime runtime = spy(water.runtime);
water.hold();
try (HydrologyRuntime.PreviewScope outer = runtime.preview()) {
assertTrue(runtime.raster(0, 0).provisional());
try (HydrologyRuntime.PreviewScope inner = runtime.preview()) {
assertTrue(runtime.raster(1, 0).provisional());
}
assertTrue(runtime.raster(0, 1).provisional());
}
water.release();
clearInvocations(runtime);
assertFalse(runtime.raster(400, 400).provisional());
verify(runtime, never()).rasterPreview(anyInt(), anyInt());
}
}
@Test(timeout = 300_000L) @Test(timeout = 300_000L)
public void createSavesPlansUnderTheWorldFolder() throws Exception { public void createSavesPlansUnderTheWorldFolder() throws Exception {
Path worldFolder = temporaryFolder.newFolder("world").toPath(); Path worldFolder = temporaryFolder.newFolder("world").toPath();
@@ -4,6 +4,7 @@ import org.junit.Test;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Random; import java.util.Random;
import java.util.Set; import java.util.Set;
@@ -32,6 +33,35 @@ public class HeightLatticeTest {
private static final int AREA = 3 * WINDOW; private static final int AREA = 3 * WINDOW;
private static final int DISTINCT_ACROSS_NINE = 25 * WINDOW * WINDOW; private static final int DISTINCT_ACROSS_NINE = 25 * WINDOW * WINDOW;
@Test
public void neighbouringCellsSpreadAcrossConcurrentMapBuckets() {
int[] buckets = new int[65_536];
int occupied = 0;
int largest = 0;
Set<Long> identities = new HashSet<>();
for (int z = -96; z < 96; z++) {
for (int x = -96; x < 96; x++) {
long key = HeightLattice.key(x, z);
assertTrue(identities.add(key));
int hash = Long.hashCode(key);
int bucket = (hash ^ (hash >>> 16)) & (buckets.length - 1);
if (buckets[bucket]++ == 0) {
occupied++;
}
largest = Math.max(largest, buckets[bucket]);
}
}
assertTrue("occupied buckets " + occupied, occupied > 25_000);
assertTrue("largest collision chain " + largest, largest < 12);
identities.clear();
int[] extremes = {Integer.MIN_VALUE, Integer.MIN_VALUE + 1, -1, 0, 1, Integer.MAX_VALUE - 1, Integer.MAX_VALUE};
for (int x : extremes) {
for (int z : extremes) {
assertTrue("distinct coordinate " + x + "," + z, identities.add(HeightLattice.key(x, z)));
}
}
}
@Test @Test
public void samplesEachCellOnceAcrossNineOverlappingWindows() { public void samplesEachCellOnceAcrossNineOverlappingWindows() {
CountingSource source = new CountingSource(); CountingSource source = new CountingSource();
@@ -317,6 +317,41 @@ public class HydrologyPlanCacheTest {
assertEquals(26, planners.windowCalls.size()); assertEquals(26, planners.windowCalls.size());
} }
@Test
public void speculativePlanningLeavesWorkersForNewAndPromotedDemand() throws Exception {
for (int threads : List.of(2, 4)) {
FakePlanners planners = new FakePlanners(unpersisted());
Gate gate = new Gate();
planners.hook = key -> {
if (key instanceof RegionKey region && Math.abs(region.rx()) < 10 && Math.abs(region.rz()) < 10) {
gate.pass();
}
};
ExecutorService workers = pool(threads);
HydrologyPlanCache cache = cache(planners, workers, () -> false);
CompletableFuture<Void> prepared = cache.prepareWindowsAround(0, 0, 31, 31, 2);
try {
await(() -> gate.arrivals() >= threads / 2, "speculative planning reaches its concurrency limit");
WindowKey demanded = new WindowKey(400, 400);
assertEquals(demanded, cache.demandWindow(demanded).get(5L, TimeUnit.SECONDS).plan().key());
assertEquals("prefetch leaves workers available for demand", threads / 2, gate.arrivals());
WindowKey promoted = new WindowKey(800, 800);
CompletableFuture<IndexedWindow> prefetched = cache.prefetchWindow(promoted);
workers.submit(() -> null).get(5L, TimeUnit.SECONDS);
assertFalse("the prefetch waits for speculative capacity", prefetched.isDone());
IndexedWindow ready = cache.demandWindow(promoted).get(5L, TimeUnit.SECONDS);
assertEquals(promoted, ready.plan().key());
assertSame("demand promotes the queued prefetch", ready, prefetched.get(5L, TimeUnit.SECONDS));
assertEquals(threads / 2, gate.arrivals());
} finally {
gate.release();
}
prepared.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertEquals("all prepared windows finish after demand", 27, planners.windowCalls.size());
}
}
@Test @Test
public void plansWithRealPlannersAndReloadsFromStore() throws Exception { public void plansWithRealPlannersAndReloadsFromStore() throws Exception {
HydrologySettings settings = settings(new IrisHydrology()); HydrologySettings settings = settings(new IrisHydrology());
@@ -412,13 +447,14 @@ public class HydrologyPlanCacheTest {
} }
@Test @Test
public void leastRecentlyUsedWindowIsEvicted() { public void leastRecentlyUsedWindowIsEvicted() throws Exception {
FakePlanners planners = new FakePlanners(unpersisted()); FakePlanners planners = new FakePlanners(unpersisted());
HydrologyPlanCache cache = cache(planners, pool(4), () -> false); HydrologyPlanCache cache = cache(planners, pool(4), () -> false);
WindowKey kept = new WindowKey(0, 0); WindowKey kept = new WindowKey(0, 0);
WindowKey stale = new WindowKey(1, 0); WindowKey stale = new WindowKey(1, 0);
IndexedWindow keptWindow = cache.window(kept); IndexedWindow keptWindow = cache.window(kept);
cache.window(stale); cache.window(stale);
assertEquals(0L, cache.windowEvictions());
List<WindowKey> requested = new ArrayList<>(List.of(kept, stale)); List<WindowKey> requested = new ArrayList<>(List.of(kept, stale));
for (int index = 0; index < 300; index++) { for (int index = 0; index < 300; index++) {
WindowKey key = new WindowKey(index, 7); WindowKey key = new WindowKey(index, 7);
@@ -435,8 +471,13 @@ public class HydrologyPlanCacheTest {
} }
} }
assertTrue("the cache stays bounded: " + ready, ready < requested.size() / 2); assertTrue("the cache stays bounded: " + ready, ready < requested.size() / 2);
await(() -> cache.windowEvictions() == requested.size() - 128, "all excess windows are evicted");
long evictions = cache.windowEvictions();
assertSame(keptWindow, cache.window(kept));
assertEquals("a resident cache hit does not evict a window", evictions, cache.windowEvictions());
cache.window(stale); cache.window(stale);
assertEquals("an evicted window is planned again on demand", 2, planners.windowCalls(stale)); assertEquals("an evicted window is planned again on demand", 2, planners.windowCalls(stale));
await(() -> cache.windowEvictions() == evictions + 1, "replanning an evicted window advances the eviction count");
} }
@Test @Test
@@ -30,7 +30,20 @@ public class HydrologySettingsTest {
private static final int SEA_LEVEL = 62; private static final int SEA_LEVEL = 62;
private static final int MIN_Y = -64; private static final int MIN_Y = -64;
private static final int MAX_Y = 320; private static final int MAX_Y = 320;
private static final long DEFAULT_FINGERPRINT = 3973071586549334460L; private static final long DEFAULT_FINGERPRINT = 8818472145657699942L;
@Test
public void densityIsBoundedAndChangesThePlanIdentity() {
HydrologySettings full = compile(new IrisHydrology());
HydrologySettings half = compile(new IrisHydrology().setDensity(0.5D));
assertEquals(1f, full.density(), 0f);
assertEquals(0.5f, half.density(), 0f);
assertTrue(full.fingerprint() != half.fingerprint());
assertEquals(full.tiers(), half.tiers());
for (double invalid : new double[]{-0.01D, 1.01D, Double.NaN, Double.POSITIVE_INFINITY}) {
assertRejected(new IrisHydrology().setDensity(invalid), "density");
}
}
@Rule @Rule
public final TemporaryFolder temporary = new TemporaryFolder(); public final TemporaryFolder temporary = new TemporaryFolder();
@@ -34,6 +34,14 @@ public class RegionPlannerTest {
private static final RegionKey ORIGIN = new RegionKey(0, 0); private static final RegionKey ORIGIN = new RegionKey(0, 0);
private static final RegionKey EAST = new RegionKey(1, 0); private static final RegionKey EAST = new RegionKey(1, 0);
@Test
public void zeroDensityRemovesMajorChannelsAndLakes() {
HydrologySettings settings = HydrologySettings.compile(new IrisHydrology().setDensity(0D), SEED, SEA_LEVEL,
SyntheticTerrain.MIN_Y, SyntheticTerrain.MAX_Y);
assertTrue(planner(settings, SyntheticTerrain.coneIsland(2048D, 2048D, 1700D, 240D, 6)).plan(ORIGIN).majorReaches().isEmpty());
assertTrue(planner(settings, SyntheticTerrain.crater(2048D, 2048D, 150, 700D)).plan(ORIGIN).lakes().isEmpty());
}
@Test @Test
public void coneIslandMajorReachesEndInOcean() { public void coneIslandMajorReachesEndInOcean() {
HydrologySettings settings = settings(); HydrologySettings settings = settings();
@@ -41,6 +41,14 @@ public class WindowPlannerTest {
private static final WindowKey LAND = new WindowKey(4, 4); private static final WindowKey LAND = new WindowKey(4, 4);
private static final int SEAM_DISTANCE = 256; private static final int SEAM_DISTANCE = 256;
@Test
public void zeroDensityRemovesLocalChannelsAndPonds() {
HydrologySettings settings = settings(new IrisHydrology().setDensity(0D));
WindowPlan plan = planner(settings, SyntheticTerrain.trunkValley()).plan(LAND, noRegions(LAND));
assertTrue(plan.reaches().isEmpty());
assertTrue(plan.ponds().isEmpty());
}
@Test @Test
public void burnedTrunkCapturesLocalDrainage() { public void burnedTrunkCapturesLocalDrainage() {
HydrologySettings settings = settings(new IrisHydrology()); HydrologySettings settings = settings(new IrisHydrology());
@@ -28,12 +28,51 @@ import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class CavePoolFillerTest { public class CavePoolFillerTest {
private static final long SEED = 1337L; private static final long SEED = 1337L;
private static final int FLOOR = 50; private static final int FLOOR = 50;
@Test
public void globalDensityThinsCandidateColumnsBeforeSamplingCaves() {
IrisHydrology config = new IrisHydrology().setDensity(0.5D);
config.getUnderground().getCavePools().setDensity(8D);
CavePoolFiller filler = new CavePoolFiller(HydrologySettings.compile(config, SEED, 63, 0, 384));
CarvedTerrain carved = mock(CarvedTerrain.class);
MantleWriter writer = mock(MantleWriter.class);
Set<Long> columns = new HashSet<>();
when(carved.surface(anyInt(), anyInt())).thenAnswer(invocation -> {
int x = invocation.getArgument(0);
int z = invocation.getArgument(1);
columns.add(key(x, 0, z));
return 0;
});
for (int chunkX = 0; chunkX < 1024; chunkX++) {
filler.fill(chunkX, 0, carved, writer, SEED);
}
assertTrue("half density samples approximately half the candidate columns", columns.size() > 3600 && columns.size() < 4500);
Set<Long> repeated = new HashSet<>(columns);
columns.clear();
for (int chunkX = 1023; chunkX >= 0; chunkX--) {
filler.fill(chunkX, 0, carved, writer, SEED);
}
assertEquals(repeated, columns);
verifyNoInteractions(writer);
}
@Test
public void zeroGlobalDensityDoesNotReadCaves() {
IrisHydrology config = new IrisHydrology().setDensity(0D);
config.getUnderground().getCavePools().setDensity(8D);
CavePoolFiller filler = new CavePoolFiller(HydrologySettings.compile(config, SEED, 63, 0, 384));
CarvedTerrain carved = mock(CarvedTerrain.class);
MantleWriter writer = mock(MantleWriter.class);
filler.fill(0, 0, carved, writer, SEED);
verifyNoInteractions(carved, writer);
}
@Test @Test
public void poolLevelIsHighestContainedLevelMinusFreeboard() { public void poolLevelIsHighestContainedLevelMinusFreeboard() {
VoxelWorld world = roomWithSill(); VoxelWorld world = roomWithSill();
@@ -21,6 +21,31 @@ import static org.junit.Assert.assertTrue;
public class SeaInletPlannerTest { public class SeaInletPlannerTest {
private static final int SEA = InletTerrain.SEA; private static final int SEA = InletTerrain.SEA;
@Test
public void globalDensityThinsInletSitesWithoutChangingTheirGeometry() {
InletTerrain terrain = InletTerrain.cliff(30D);
IrisHydrology full = new IrisHydrology();
IrisHydrology half = new IrisHydrology().setDensity(0.5D);
full.getUnderground().getSeaInlets().setSpacing(256);
half.getUnderground().getSeaInlets().setSpacing(256);
int baseline = 0;
int retained = 0;
for (int offset = 0; offset < 32; offset++) {
InletTerrain shifted = terrain.shifted(0, offset * 512);
List<SeaInlet> all = shifted.plan(InletTerrain.settings(full));
List<SeaInlet> reduced = shifted.plan(InletTerrain.settings(half));
baseline += all.size();
retained += reduced.size();
for (SeaInlet inlet : reduced) {
assertTrue("retained inlets keep the same full passage", all.contains(inlet));
}
assertEquals(reduced, shifted.plan(InletTerrain.settings(half)));
}
assertTrue("cliff provides enough candidates", baseline >= 32);
assertTrue("half density retains approximately half the sites", retained > baseline * 0.3 && retained < baseline * 0.7);
assertTrue(terrain.plan(InletTerrain.settings(new IrisHydrology().setDensity(0D))).isEmpty());
}
@Test @Test
public void inletStartsAtOceanCell() { public void inletStartsAtOceanCell() {
InletTerrain terrain = InletTerrain.cliff(30D); InletTerrain terrain = InletTerrain.cliff(30D);
@@ -10,15 +10,24 @@ import art.arcane.iris.generation.hydrology.grid.HydrologySampleBuffer;
import art.arcane.iris.generation.hydrology.grid.HydrologyTerrainSource; import art.arcane.iris.generation.hydrology.grid.HydrologyTerrainSource;
import art.arcane.iris.generation.hydrology.grid.PriorityFlood; import art.arcane.iris.generation.hydrology.grid.PriorityFlood;
import art.arcane.iris.generation.hydrology.plan.HydrologySettings; import art.arcane.iris.generation.hydrology.plan.HydrologySettings;
import art.arcane.iris.generation.hydrology.plan.IndexedWindow;
import art.arcane.iris.generation.hydrology.plan.RegionKey;
import art.arcane.iris.generation.hydrology.plan.RegionPlan;
import art.arcane.iris.generation.hydrology.plan.WindowKey; import art.arcane.iris.generation.hydrology.plan.WindowKey;
import art.arcane.iris.generation.hydrology.plan.WindowPlan;
import art.arcane.iris.generation.hydrology.raster.ChunkRasterizer;
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
import art.arcane.iris.generation.hydrology.raster.ReachIndex;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import org.junit.Test; import org.junit.Test;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.BitSet; import java.util.BitSet;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.DoubleBinaryOperator; import java.util.function.DoubleBinaryOperator;
@@ -38,6 +47,77 @@ public class PondPlannerTest {
private static final float BOWL_SPILL = 112f; private static final float BOWL_SPILL = 112f;
private static final WindowKey ORIGIN = new WindowKey(0, 0); private static final WindowKey ORIGIN = new WindowKey(0, 0);
@Test
public void smallSeededPondFollowsItsBowlAtBlockResolution() {
HydrologySettings settings = settings(config -> {
seededOnly(config);
config.getPonds().getSeeded().setDensity(1D).setRadius(new KList<>(8, 8)).setDepth(new KList<>(6, 6))
.setAlongChannels(false);
config.getPonds().setFreeboard(new KList<>(1, 1));
config.getPonds().getShore().setRoundness(0D);
}, MIN_Y, MAX_Y);
List<Pond> ponds = plan(settings, new Terrain((x, z) -> 100D, 1f, ALLOW_ALL, null, MIN_Y, MAX_Y),
0, 0, 64, 64, new BitSet(), List.of());
Pond pond = single(ponds);
PondMask mask = pond.mask();
assertEquals(1, mask.step());
int wet = 0;
int minX = Integer.MAX_VALUE;
int minZ = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int maxZ = Integer.MIN_VALUE;
for (int j = 0; j < mask.depth(); j++) {
for (int i = 0; i < mask.width(); i++) {
if (mask.node(i, j)) {
wet++;
minX = Math.min(minX, i);
minZ = Math.min(minZ, j);
maxX = Math.max(maxX, i);
maxZ = Math.max(maxZ, j);
}
}
}
int rectangle = (maxX - minX + 1) * (maxZ - minZ + 1);
assertTrue("rounded bowl occupies " + wet + " of " + rectangle + " bounding blocks", wet < rectangle * 0.9D);
assertFalse(mask.node(minX, minZ));
assertFalse(mask.node(maxX, maxZ));
assertTrue(mask.contains(pond.floorX(), pond.floorZ()));
}
@Test
public void globalDensityHalvesSaturatedSeededSiteAcceptance() {
HydrologySettings full = settings(config -> {
seededOnly(config);
config.getPonds().getSeeded().setDensity(16D).setAlongChannels(false);
}, MIN_Y, MAX_Y);
HydrologySettings half = settings(config -> {
seededOnly(config);
config.setDensity(0.5D);
config.getPonds().getSeeded().setDensity(16D).setAlongChannels(false);
}, MIN_Y, MAX_Y);
Terrain terrain = new Terrain((x, z) -> 100D, 1f, ALLOW_ALL, null, MIN_Y, MAX_Y);
Set<Long> all = seededSites(full, terrain);
Set<Long> reduced = seededSites(half, terrain);
assertTrue("full candidates " + all.size(), all.size() >= 450);
assertTrue(all.containsAll(reduced));
double fraction = reduced.size() / (double) all.size();
assertTrue("retained fraction " + fraction, fraction > 0.44D && fraction < 0.56D);
}
private static Set<Long> seededSites(HydrologySettings settings, Terrain terrain) {
Set<Long> sites = new HashSet<>();
for (int window = 0; window < 32; window++) {
int x = window * WindowKey.SIZE;
Sampled sampled = sample(terrain, 8, x, 0, 64, 64);
Arrays.fill(sampled.samples().pondDensity(), 100f);
CupSet cups = new PondPlanner(settings).subtractCups(sampled.grid(), new WindowKey(window, 0), sampled.samples(), new BitSet());
for (CupSet.Cup cup : cups.within(x, 0, x + WindowKey.SIZE, WindowKey.SIZE)) {
sites.add(((long) cup.x() << 32) | (cup.z() & 0xFFFFFFFFL));
}
}
return sites;
}
@Test @Test
public void naturalBasinPondSitsBelowSpill() { public void naturalBasinPondSitsBelowSpill() {
HydrologySettings settings = settings(PondPlannerTest::naturalOnly, MIN_Y, MAX_Y); HydrologySettings settings = settings(PondPlannerTest::naturalOnly, MIN_Y, MAX_Y);
@@ -187,6 +267,7 @@ public class PondPlannerTest {
assertTrue(pond.mask().contains(200, 256)); assertTrue(pond.mask().contains(200, 256));
assertTrue(pond.mask().contains(312, 256)); assertTrue(pond.mask().contains(312, 256));
assertContained(pond, terrain); assertContained(pond, terrain);
assertRasterRimContained(settings, pond, terrain);
} }
@Test @Test
@@ -527,6 +608,43 @@ public class PondPlannerTest {
return new PondMask(x, 208, 16, 1, 7, new long[]{0b1111111L}); return new PondMask(x, 208, 16, 1, 7, new long[]{0b1111111L});
} }
private static void assertRasterRimContained(HydrologySettings settings, Pond pond, DoubleBinaryOperator natural) {
Map<Long, ChunkWaterRaster> rasters = new HashMap<>();
ChunkRasterizer rasterizer = new ChunkRasterizer(settings);
int raised = 0;
int[][] sides = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (int[] wet : wetNodes(pond.mask())) {
for (int[] side : sides) {
int x = wet[0] + side[0];
int z = wet[1] + side[1];
if (pond.mask().contains(x, z)) {
continue;
}
long key = ((long) (x >> 4) << 32) | ((z >> 4) & 0xFFFFFFFFL);
ChunkWaterRaster raster = rasters.get(key);
if (raster == null) {
WindowPlan window = new WindowPlan(WindowKey.containing(x, z), List.of(), List.of(pond), List.of());
IndexedWindow indexed = new IndexedWindow(window, ReachIndex.build(List.of(), List.of(pond)));
List<RegionPlan> regions = new ArrayList<>();
for (RegionKey region : ChunkRasterizer.lakeRegions(x >> 4, z >> 4)) {
regions.add(new RegionPlan(region, List.of(), List.of()));
}
raster = rasterizer.rasterize(x >> 4, z >> 4, (px, pz) -> (int) Math.round(natural.applyAsDouble(px, pz)),
List.of(indexed), regions);
rasters.put(key, raster);
}
int ground = (int) Math.round(natural.applyAsDouble(x, z));
int actual = raster.terrainHeight(x & 15, z & 15);
assertTrue("dry rim contains the water at " + x + "," + z, actual >= pond.level());
assertTrue("rim fill remains bounded at " + x + "," + z, actual <= ground + settings.deformation().maxFill());
if (actual > ground) {
raised++;
}
}
}
assertTrue("sub-lattice rim needs real raster berms", raised > 0);
}
private static Pond lake(PondMask mask) { private static Pond lake(PondMask mask) {
return new Pond(42L, PondKind.LAKE, 110, 100, mask.originX(), mask.originZ(), 111f, mask, 0L, 0, "water", mask.originX(), mask.originZ()); return new Pond(42L, PondKind.LAKE, 110, 100, mask.originX(), mask.originZ(), 111f, mask, 0L, 0, "water", mask.originX(), mask.originZ());
} }
@@ -548,6 +666,7 @@ public class PondPlannerTest {
private static void assertContained(Pond pond, DoubleBinaryOperator natural) { private static void assertContained(Pond pond, DoubleBinaryOperator natural) {
PondMask mask = pond.mask(); PondMask mask = pond.mask();
int step = mask.step(); int step = mask.step();
int maxFill = new IrisHydrology().getDeformation().getMaxFill();
List<int[]> nodes = wetNodes(mask); List<int[]> nodes = wetNodes(mask);
assertFalse(nodes.isEmpty()); assertFalse(nodes.isEmpty());
assertTrue("floor " + pond.floor() + " level " + pond.level(), pond.floor() < pond.level()); assertTrue("floor " + pond.floor() + " level " + pond.level(), pond.floor() < pond.level());
@@ -557,8 +676,8 @@ public class PondPlannerTest {
int x = node[0] + direction[0]; int x = node[0] + direction[0];
int z = node[1] + direction[1]; int z = node[1] + direction[1];
if (!mask.contains(x, z)) { if (!mask.contains(x, z)) {
float edge = (float) natural.applyAsDouble(x, z); long edge = Math.round(natural.applyAsDouble(x, z));
assertTrue("edge " + x + "," + z + " natural " + edge + " level " + pond.level(), edge >= pond.level()); assertTrue("edge " + x + "," + z + " natural " + edge + " level " + pond.level(), edge + maxFill >= pond.level());
} }
} }
} }
@@ -23,6 +23,10 @@ import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.studio.map.components.MapLayer;
import art.arcane.iris.studio.map.components.MapTile;
import art.arcane.iris.studio.map.components.MapTileRenderer;
import art.arcane.iris.studio.map.components.TileType;
import art.arcane.iris.structure.object.IObjectPlacer; import art.arcane.iris.structure.object.IObjectPlacer;
import art.arcane.iris.testsupport.FakeHydrology; import art.arcane.iris.testsupport.FakeHydrology;
import art.arcane.iris.testsupport.PlatformLeakGuard; import art.arcane.iris.testsupport.PlatformLeakGuard;
@@ -44,12 +48,14 @@ import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CancellationException; import java.util.concurrent.CancellationException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
@@ -208,6 +214,35 @@ public class IrisComplexHeightStreamTest {
} }
} }
@Test(timeout = 15_000L)
public void mapHeightPreviewReturnsWhilePlanningAndRefreshesToFinalTerrain() throws Exception {
try (HydrologyFixture fixture = new HydrologyFixture(hydrologyPack())) {
fixture.hydrology.hold();
MapTileRenderer renderer = new MapTileRenderer(fixture.engine);
MapLayer layer = MapLayer.of(TileType.HEIGHT);
long before = ProvisionalSampling.revision();
MapTile preview = renderer.renderTile(0D, 0D, 1D, 16, layer, true, () -> false);
assertEquals(HydrologyFixture.NATURAL, preview.heightAt(8, 8), 0D);
assertNotEquals(before, ProvisionalSampling.revision());
fixture.hydrology.release();
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10L);
MapTile settled;
do {
assertTrue("Map height preview never settled", System.nanoTime() < deadline);
Thread.sleep(10L);
before = ProvisionalSampling.revision();
settled = renderer.renderTile(0D, 0D, 1D, 16, layer, true, () -> false);
} while (before != ProvisionalSampling.revision());
double terrain = fixture.complex.getHeightStream().getDouble(8D, 8D);
assertNotEquals(HydrologyFixture.NATURAL, terrain, 0D);
assertEquals(terrain, settled.heightAt(8, 8), 0D);
assertEquals(before, ProvisionalSampling.revision());
}
}
@Test(timeout = 120_000L) @Test(timeout = 120_000L)
public void waterStreamsFollowTheRaster() throws Exception { public void waterStreamsFollowTheRaster() throws Exception {
try (HydrologyFixture fixture = new HydrologyFixture(hydrologyPack())) { try (HydrologyFixture fixture = new HydrologyFixture(hydrologyPack())) {
@@ -83,7 +83,7 @@ public class SchemaBuilderIdentityTest {
@ClassRule @ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
private static final String EXPECTED_SCHEMA_DIGEST = "dd1ea7f0361dfad24c4800e75f1e26e951b4f775aa1b541328724a8635afeeca"; private static final String EXPECTED_SCHEMA_DIGEST = "6935e8d3914e5cde852522151d87fe898f79de3b394826e544c5c5bfbfceba57";
private static final List<Class<?>> SCHEMA_ROOTS = List.of( private static final List<Class<?>> SCHEMA_ROOTS = List.of(
IrisDimension.class, IrisDimension.class,
@@ -13,6 +13,8 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier; import java.util.function.BooleanSupplier;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@@ -102,6 +104,42 @@ public class VisionRenderControllerTest {
} }
} }
@Test
public void zoomRetainsCompleteWaterTilesWhileDetailedTilesAreProvisional() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTile overview = mock(MapTile.class);
MapTile incomplete = mock(MapTile.class);
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
.thenAnswer(invocation -> {
double scale = invocation.getArgument(2);
if (scale < 4D) {
ProvisionalSampling.mark();
return incomplete;
}
return overview;
});
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 128D, 128D, 4D, 64, 64, false));
await(() -> controller.progress(coarse).ready() == 1);
VisionRenderController.Frame fine = controller.request(spec(renderer, 1L));
await(() -> fine.tiles().getFirst().tile() != null);
VisionRenderController.VisibleTile visible = fine.tiles().getFirst();
assertTrue(visible.provisional());
assertEquals(0, controller.progress(fine).ready());
VisionRenderController.Fallback retained = controller.fallback(fine, visible);
assertNotNull(retained);
assertSame(overview, retained.pieces().getFirst().tile());
assertEquals(16, retained.pieces().getFirst().sourceSize());
VisionRenderController.Frame returned = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 128D, 128D, 4D, 64, 64, false));
assertSame(overview, returned.tiles().getFirst().tile());
assertFalse(returned.tiles().getFirst().provisional());
}
}
@Test @Test
public void closeRejectsLatePublication() throws Exception { public void closeRejectsLatePublication() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class); MapTileRenderer renderer = mock(MapTileRenderer.class);
@@ -2,8 +2,10 @@ package art.arcane.iris.studio.render;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime; import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.hydrology.engine.HydrologyPlanPreview;
import art.arcane.iris.generation.hydrology.plan.HydrologySettings; import art.arcane.iris.generation.hydrology.plan.HydrologySettings;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType; import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
import art.arcane.iris.generation.hydrology.water.Pond; import art.arcane.iris.generation.hydrology.water.Pond;
import art.arcane.iris.generation.hydrology.water.Reach; import art.arcane.iris.generation.hydrology.water.Reach;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
@@ -93,6 +95,34 @@ public class IrisRendererRiverTest {
} }
} }
@Test
public void zoomKeepsResidentWaterPlansUntilDetailedRastersAreReady() {
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
Pond pond = WaterScene.pond(32L, 62, 16, 16, 10);
when(runtime.enabled()).thenReturn(true);
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt()))
.thenReturn(new HydrologyPlanPreview(List.of(), List.of(pond), List.of(), List.of(), true));
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.PROVISIONAL);
IrisRenderer renderer = new IrisRenderer(engine(runtime));
BufferedImage overview = renderer.renderStudio(0D, 0D, 256D, 64, RenderType.RIVER, () -> false);
int water = IrisRenderer.hydrologyFeatureColor(HydrologyFeatureType.POND);
assertEquals(water, overview.getRGB(4, 4));
for (int step : new int[]{2, 1}) {
long before = ProvisionalSampling.revision();
BufferedImage detailed = renderer.renderStudio(0D, 0D, step * 64D, 64, RenderType.RIVER, () -> false);
assertEquals(water, detailed.getRGB(16 / step, 16 / step));
assertNotEquals(before, ProvisionalSampling.revision());
}
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.EMPTY);
long before = ProvisionalSampling.revision();
BufferedImage settled = renderer.renderStudio(0D, 0D, 64D, 64, RenderType.RIVER, () -> false);
assertEquals(NO_RIVER, settled.getRGB(16, 16));
assertEquals(before, ProvisionalSampling.revision());
}
@Test @Test
public void channelPixelsShowTheirTier() throws Exception { public void channelPixelsShowTheirTier() throws Exception {
HydrologySettings settings = FakeHydrology.settings(SEA_LEVEL); HydrologySettings settings = FakeHydrology.settings(SEA_LEVEL);
@@ -6,8 +6,10 @@ import org.junit.Test;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import java.awt.Color; import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.Rectangle; import java.awt.Rectangle;
import java.awt.event.MouseEvent; import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.lang.reflect.Field; import java.lang.reflect.Field;
@@ -125,6 +127,82 @@ public class PregenMapPanelTest {
SwingUtilities.invokeAndWait(extreme::disposeMap); SwingUtilities.invokeAndWait(extreme::disposeMap);
} }
@Test
public void wheelZoomPreservesTheChunkUnderThePointerAndRetainedPixels() throws Exception {
PregenMapPanel panel = panel(new PregenRenderSnapshot.Bounds(-32, -32, 31, 31));
SwingUtilities.invokeAndWait(() -> panel.setSize(802, 602));
panel.submit(-7, -19, Color.GREEN);
flush(panel);
BufferedImage retained = image(panel);
String before = tooltip(panel, 337, 123);
SwingUtilities.invokeAndWait(() -> panel.dispatchEvent(new MouseWheelEvent(panel,
MouseEvent.MOUSE_WHEEL, 1L, 0, 337, 123, 0, false,
MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, -4)));
assertEquals(before, tooltip(panel, 337, 123));
assertTrue(panel.zoom() > 1D);
assertTrue(retained == image(panel));
assertEquals(Color.GREEN.getRGB(), image(panel).getRGB(25, 13));
SwingUtilities.invokeAndWait(panel::disposeMap);
}
@Test
public void draggingPansTheMapAndResetFitsBesideTheSidebar() throws Exception {
PregenMapPanel panel = panel(new PregenRenderSnapshot.Bounds(-32, -32, 31, 31));
SwingUtilities.invokeAndWait(() -> {
panel.setSize(1000, 600);
panel.setCoveredLeftWidth(300);
});
String initial = tooltip(panel, 650, 300);
assertNull(tooltip(panel, 250, 300));
SwingUtilities.invokeAndWait(() -> {
panel.dispatchEvent(new MouseEvent(panel, MouseEvent.MOUSE_PRESSED, 1L,
MouseEvent.BUTTON1_DOWN_MASK, 650, 300, 1, false, MouseEvent.BUTTON1));
panel.dispatchEvent(new MouseEvent(panel, MouseEvent.MOUSE_DRAGGED, 2L,
MouseEvent.BUTTON1_DOWN_MASK, 710, 350, 0, false, MouseEvent.NOBUTTON));
panel.dispatchEvent(new MouseEvent(panel, MouseEvent.MOUSE_RELEASED, 3L,
0, 710, 350, 1, false, MouseEvent.BUTTON1));
});
assertEquals(initial, tooltip(panel, 710, 350));
assertFalse(initial.equals(tooltip(panel, 650, 300)));
SwingUtilities.invokeAndWait(() -> {
panel.zoomIn();
panel.resetView();
});
assertEquals(1D, panel.zoom(), 0D);
assertEquals(initial, tooltip(panel, 650, 300));
SwingUtilities.invokeAndWait(panel::disposeMap);
}
@Test
public void zoomBoundsAndGridPaintingHandleExtremeMapCoordinates() throws Exception {
PregenMapPanel panel = panel(new PregenRenderSnapshot.Bounds(
Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE));
SwingUtilities.invokeAndWait(() -> {
panel.setSize(820, 520);
panel.toggleGrid();
for (int index = 0; index < 100; index++) {
panel.zoomIn();
}
assertEquals(128D, panel.zoom(), 0D);
BufferedImage target = new BufferedImage(820, 520, BufferedImage.TYPE_INT_RGB);
Graphics2D canvas = target.createGraphics();
try {
panel.paint(canvas);
panel.panBy(1_000_000, -1_000_000);
panel.paint(canvas);
for (int index = 0; index < 100; index++) {
panel.zoomOut();
}
assertEquals(0.25D, panel.zoom(), 0D);
panel.resetView();
panel.paint(canvas);
} finally {
canvas.dispose();
panel.disposeMap();
}
});
}
private static String tooltip(PregenMapPanel panel, int x, int y) throws Exception { private static String tooltip(PregenMapPanel panel, int x, int y) throws Exception {
String[] result = new String[1]; String[] result = new String[1];
SwingUtilities.invokeAndWait(() -> result[0] = panel.getToolTipText( SwingUtilities.invokeAndWait(() -> result[0] = panel.getToolTipText(
@@ -11,8 +11,12 @@ import javax.swing.JButton;
import javax.swing.JComponent; import javax.swing.JComponent;
import javax.swing.JLabel; import javax.swing.JLabel;
import javax.swing.JTextArea; import javax.swing.JTextArea;
import javax.swing.JToggleButton;
import javax.swing.JViewport;
import javax.swing.KeyStroke; import javax.swing.KeyStroke;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import java.awt.Component;
import java.awt.Container;
import java.awt.event.ActionEvent; import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent; import java.awt.event.KeyEvent;
import java.lang.reflect.Constructor; import java.lang.reflect.Constructor;
@@ -27,6 +31,50 @@ import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
public class PregenRendererTest { public class PregenRendererTest {
@Test
public void smallWindowOpensAtTheProgressHeaderWithoutScrollingAway() throws Exception {
PregenRenderer[] result = new PregenRenderer[1];
onEdt(() -> {
result[0] = renderer(new Source(), () -> { });
result[0].setSize(820, 520);
layout(result[0]);
});
try {
onEdt(() -> layout(result[0]));
onEdt(() -> assertScrollAtTop(result[0]));
} finally {
onEdt(result[0]::close);
}
}
@Test
public void mapShortcutsZoomPanResetAndKeepGridControlSynchronized() throws Exception {
onEdt(() -> {
PregenRenderer renderer = renderer(new Source(), () -> { });
try {
PregenMapPanel map = field(renderer, "map", PregenMapPanel.class);
map.setSize(1000, 600);
map.setCoveredLeftWidth(300);
shortcut(renderer, KeyEvent.VK_EQUALS);
assertTrue(map.zoom() > 1D);
shortcut(renderer, KeyEvent.VK_LEFT);
shortcut(renderer, KeyEvent.VK_R);
assertEquals(1D, map.zoom(), 0D);
shortcut(renderer, KeyEvent.VK_G);
assertTrue(map.isGridVisible());
JToggleButton grid = field(renderer, "gridToggle", JToggleButton.class);
assertTrue(grid.isSelected());
grid.doClick(0);
assertFalse(map.isGridVisible());
assertFalse(grid.isSelected());
shortcut(renderer, KeyEvent.VK_SLASH);
shortcut(renderer, KeyEvent.VK_ESCAPE);
} finally {
renderer.close();
}
});
}
@Test @Test
public void keyboardAndButtonActivationToggleExactlyOnceAndTerminalControlsAreDisabled() throws Exception { public void keyboardAndButtonActivationToggleExactlyOnceAndTerminalControlsAreDisabled() throws Exception {
onEdt(() -> { onEdt(() -> {
@@ -155,6 +203,32 @@ public class PregenRendererTest {
refresh.invoke(renderer); refresh.invoke(renderer);
} }
private static void shortcut(PregenRenderer renderer, int keyCode) {
Object action = renderer.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
.get(KeyStroke.getKeyStroke(keyCode, 0));
renderer.getActionMap().get(action).actionPerformed(new ActionEvent(renderer, 0, "shortcut"));
}
private static void layout(Container container) {
container.doLayout();
for (Component component : container.getComponents()) {
if (component instanceof Container child) {
layout(child);
}
}
}
private static void assertScrollAtTop(Container container) {
for (Component component : container.getComponents()) {
if (component instanceof JViewport viewport) {
assertEquals(0, viewport.getViewPosition().y);
}
if (component instanceof Container child) {
assertScrollAtTop(child);
}
}
}
private static <T> T field(PregenRenderer renderer, String name, Class<T> type) throws Exception { private static <T> T field(PregenRenderer renderer, String name, Class<T> type) throws Exception {
Field field = PregenRenderer.class.getDeclaredField(name); Field field = PregenRenderer.class.getDeclaredField(name);
field.setAccessible(true); field.setAccessible(true);
@@ -68,6 +68,11 @@ public class IrisRiverSchemaTest {
JSONObject fluid = referencedProperties(definitions, properties.getJSONObject("fluids").getJSONObject("items")); JSONObject fluid = referencedProperties(definitions, properties.getJSONObject("fluids").getJSONObject("items"));
assertEquals("boolean", properties.getJSONObject("enabled").getString("type")); assertEquals("boolean", properties.getJSONObject("enabled").getString("type"));
JSONObject density = properties.getJSONObject("density");
assertEquals("number", density.getString("type"));
assertEquals(0D, density.getDouble("minimum"), 0D);
assertEquals(1D, density.getDouble("maximum"), 0D);
assertTrue(density.getString("description").contains("Default Value is 1.0"));
assertEquals("boolean", properties.getJSONObject("persist").getString("type")); assertEquals("boolean", properties.getJSONObject("persist").getString("type"));
assertEquals(0, properties.getJSONObject("prefetchRings").getInt("minimum")); assertEquals(0, properties.getJSONObject("prefetchRings").getInt("minimum"));
assertEquals(4, properties.getJSONObject("prefetchRings").getInt("maximum")); assertEquals(4, properties.getJSONObject("prefetchRings").getInt("maximum"));
@@ -34,6 +34,8 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.Collection; import java.util.Collection;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@@ -895,7 +897,7 @@ public class GenerationRegistryContractFactoryTest {
} }
@Test @Test
public void rejectsAChangedPackBeforeCapture() throws Exception { public void rejectsAPackChangedBeforeCapture() throws Exception {
File pack = temporary.newFolder("changed-pack"); File pack = temporary.newFolder("changed-pack");
String original = GenerationPackFingerprint.compute( String original = GenerationPackFingerprint.compute(
pack.toPath(), pack.toPath(),
@@ -904,20 +906,58 @@ public class GenerationRegistryContractFactoryTest {
Files.writeString(pack.toPath().resolve("dimension.json"), "{}", StandardCharsets.UTF_8); Files.writeString(pack.toPath().resolve("dimension.json"), "{}", StandardCharsets.UTF_8);
IrisData data = mock(IrisData.class); IrisData data = mock(IrisData.class);
when(data.getDataFolder()).thenReturn(pack); when(data.getDataFolder()).thenReturn(pack);
IrisDimension dimension = spy(new IrisDimension());
dimension.setLoadKey("overworld");
stubDimensions(data, dimension);
doReturn(new KList<>()).when(dimension).getReachableBiomes(any());
doReturn(new KList<>()).when(dimension).getAllRegions(any());
PlatformRegistries registries = mock(PlatformRegistries.class);
when(registries.blockTypeKeys()).thenReturn(new KList<>());
when(registries.entityKeys()).thenReturn(new KList<>());
assertThrows( assertThrows(
IOException.class, IOException.class,
() -> GenerationRegistryContractFactory.create( () -> GenerationRegistryContractFactory.create(
data, data,
new IrisDimension(), dimension,
original, original,
DataVersion.getLatest().get(), DataVersion.getLatest().get(),
mock(PlatformRegistries.class), registries,
new FakeGenerationRegistry("runtime") new FakeGenerationRegistry("runtime")
) )
); );
} }
@Test
public void rejectsSameSizePackMutationDuringCaptureWithPreservedTimestamp() throws Exception {
File pack = temporary.newFolder("mutating-pack");
Path content = pack.toPath().resolve("content.json");
Files.writeString(content, "{\"version\":1}", StandardCharsets.UTF_8);
FileTime modified = Files.getLastModifiedTime(content);
String original = GenerationPackFingerprint.compute(pack.toPath(), GenerationPackFingerprint.CURRENT_VERSION);
IrisData data = mock(IrisData.class);
when(data.getDataFolder()).thenReturn(pack);
IrisDimension dimension = spy(new IrisDimension());
dimension.setLoadKey("overworld");
stubDimensions(data, dimension);
doReturn(new KList<>()).when(dimension).getReachableBiomes(any());
doReturn(new KList<>()).when(dimension).getAllRegions(any());
PlatformRegistries registries = mock(PlatformRegistries.class);
when(registries.blockTypeKeys()).thenAnswer(ignored -> {
Files.writeString(content, "{\"version\":2}", StandardCharsets.UTF_8);
Files.setLastModifiedTime(content, modified);
return new KList<>();
});
when(registries.entityKeys()).thenReturn(new KList<>());
IOException failure = assertThrows(IOException.class, () -> GenerationRegistryContractFactory.create(
data, dimension, original, DataVersion.getLatest().get(), registries, new FakeGenerationRegistry("runtime")));
assertEquals("Iris generation registry capture rejected a changed pack.", failure.getMessage());
assertEquals(modified, Files.getLastModifiedTime(content));
assertEquals("{\"version\":2}", Files.readString(content));
}
@Test @Test
public void rejectsAnUnloadableAdditionalDimensionBeforeCapturingItsRegistry() throws Exception { public void rejectsAnUnloadableAdditionalDimensionBeforeCapturingItsRegistry() throws Exception {
File pack = temporary.newFolder("missing-secondary-dimension"); File pack = temporary.newFolder("missing-secondary-dimension");