From 7aaf346ce3fe4dc069a993a6e3f4c6325f612ae3 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 21 Oct 2026 20:24:39 -0400 Subject: [PATCH] True placement reach bound - Add PlacementReach: centered half-extent reach from loaded object keys, scale and shrink chains, enumerated rotation kernels, translate, warp and vacuum - Size the object pass write radius and read radius from PlacementReach instead of full object width - Size floating reach from scaled and shrunk object spans - Count cave profile vacuum modes and upper-dimension effective rotation in the object reach - Keep the transition-band footprint check on its existing footprint - Add runner-backed and pack-wide reach tests --- .../mantle/MantleFloatingObjectComponent.java | 97 ++-- .../mantle/MantleObjectComponent.java | 380 +++++++------ .../generation/mantle/PlacementReach.java | 310 +++++++++++ ...tingObjectComponentBoundaryRadiusTest.java | 25 +- ...ntleObjectComponentBoundaryRadiusTest.java | 84 ++- .../mantle/PlacementReachPackTest.java | 517 ++++++++++++++++++ .../generation/mantle/PlacementReachTest.java | 407 ++++++++++++++ 7 files changed, 1549 insertions(+), 271 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/generation/mantle/PlacementReach.java create mode 100644 core/src/test/java/art/arcane/iris/generation/mantle/PlacementReachPackTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/mantle/PlacementReachTest.java diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java index 40e050675..c2d897124 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java @@ -44,7 +44,6 @@ import art.arcane.volmlib.util.documentation.ChunkCoordinates; import art.arcane.volmlib.util.mantle.flag.ReservedFlag; import art.arcane.volmlib.util.math.RNG; -import java.io.File; import java.util.HashMap; import java.util.HashSet; import java.util.IdentityHashMap; @@ -598,7 +597,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { @Override protected int computeRadius() { int maxObjectExtent = 0; - Map sizeCache = new HashMap<>(); + Map extents = new HashMap<>(); Set warnedLargeObjects = new HashSet<>(); try { IrisData data = getData(); @@ -608,15 +607,17 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { continue; } for (IrisFloatingChildBiomes entry : entries) { - maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.getFloatingObjects(), data, sizeCache, warnedLargeObjects)); - maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.getExtraObjects(), data, sizeCache, warnedLargeObjects)); - maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.getTopObjectOverrides(), data, sizeCache, warnedLargeObjects)); - maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.getBottomObjectOverrides(), data, sizeCache, warnedLargeObjects)); + PlacementScan scan = new PlacementScan(data, extents, warnedLargeObjects, + entry.hasObjectShrink() ? entry.getShrinkScale().getMaxScale() : 1D); + maxObjectExtent = Math.max(maxObjectExtent, scan.radius(entry.getFloatingObjects(), false)); + maxObjectExtent = Math.max(maxObjectExtent, scan.radius(entry.getExtraObjects(), true)); + maxObjectExtent = Math.max(maxObjectExtent, scan.radius(entry.getTopObjectOverrides(), true)); + maxObjectExtent = Math.max(maxObjectExtent, scan.radius(entry.getBottomObjectOverrides(), true)); try { IrisBiome target = entry.getRealBiome(biome, data); if (target != null) { - maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.resolveTopObjects(target), data, sizeCache, warnedLargeObjects)); - maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.resolveBottomObjects(target), data, sizeCache, warnedLargeObjects)); + maxObjectExtent = Math.max(maxObjectExtent, scan.radius(entry.resolveTopObjects(target), true)); + maxObjectExtent = Math.max(maxObjectExtent, scan.radius(entry.resolveBottomObjects(target), true)); } else { maxObjectExtent = Math.max(maxObjectExtent, MINIMUM_PLACEMENT_RADIUS); } @@ -635,44 +636,66 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { return maxObjectExtent; } - private int computePlacementRadius(KList placements, IrisData data, - Map sizeCache, - Set warnedLargeObjects) { - if (placements == null) { - return MINIMUM_PLACEMENT_RADIUS; + private final class PlacementScan { + private final IrisData data; + private final Map extents; + private final Set warnedLargeObjects; + private final double shrink; + + private PlacementScan(IrisData data, Map extents, Set warnedLargeObjects, + double shrink) { + this.data = data; + this.extents = extents; + this.warnedLargeObjects = warnedLargeObjects; + this.shrink = shrink; } - if (placements.isEmpty()) { - return 0; - } - int radius = MINIMUM_PLACEMENT_RADIUS; - for (IrisObjectPlacement placement : placements) { - if (placement == null || placement.getPlace() == null) { - continue; + + private int radius(KList placements, boolean anchored) { + if (placements == null) { + return MINIMUM_PLACEMENT_RADIUS; } - for (String objectKey : placement.getPlace()) { - try { - IrisBlockVector size = sizeCache.get(objectKey); - if (size == null) { - File file = data.getObjectLoader().findFile(objectKey); - if (file == null) { + if (placements.isEmpty()) { + return 0; + } + int radius = MINIMUM_PLACEMENT_RADIUS; + for (IrisObjectPlacement placement : placements) { + if (placement == null || placement.getPlace() == null) { + continue; + } + PlacementReach.Profile profile = PlacementReach.profile(placement); + double scale = placement.getMaximumScale(getDimension()); + for (String objectKey : placement.getPlace()) { + try { + PlacementReach.Extent extent = extent(objectKey); + if (extent == null) { continue; } - size = IrisObject.sampleSize(file); - sizeCache.put(objectKey, size); + PlacementReach.Extent scaled = extent.scaled(scale).scaled(shrink); + int reach = anchored ? profile.anchored(scaled).horizontal() : profile.of(scaled).horizontal(); + if (reach > 128 && warnedLargeObjects.add(objectKey)) { + IrisLogging.warn("Floating object " + objectKey + " has a large placement reach (" + reach + " blocks) and may increase memory usage!"); + } + radius = Math.max(radius, reach); + } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); + IrisLogging.reportError(e); } - int reach = MantleObjectComponent.calculatePlacementReach(size, placement, placement.getMaximumScale(getDimension())); - if (reach > 128 && warnedLargeObjects.add(objectKey)) { - IrisLogging.warn("Floating object " + objectKey + " has a large placement reach (" + reach + " blocks) and may increase memory usage!"); - } - radius = Math.max(radius, reach); - } catch (Throwable e) { - GenerationFailures.rethrowEngineFailure(e); - IrisLogging.reportError(e); } } + return radius; + } + + private PlacementReach.Extent extent(String objectKey) { + if (extents.containsKey(objectKey)) { + return extents.get(objectKey); + } + IrisObject object = data.getObjectLoader().load(objectKey); + PlacementReach.Extent extent = object == null ? null : PlacementReach.Extent.of(object); + extents.put(objectKey, extent); + return extent; } - return radius; } + private static final class PlacementSequence { private int ordinal; diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java index f64111b18..913c726f8 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java @@ -54,7 +54,6 @@ import art.arcane.iris.generation.subterrain.SubterrainRasterizer; import art.arcane.iris.generation.subterrain.SubterrainPlan; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.collection.KSet; import art.arcane.iris.generation.context.ChunkContext; import art.arcane.volmlib.util.documentation.BlockCoordinates; @@ -69,12 +68,12 @@ import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.generation.block.DataProvider; -import art.arcane.iris.generation.geometry.IrisBlockVector; -import java.io.IOException; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.EnumSet; +import java.util.HashMap; import java.util.HashSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; @@ -94,6 +93,7 @@ public class MantleObjectComponent extends IrisMantleComponent { private final ObjectSourcePlanCache sourcePlans; private final AtomicLong lastSourcePlanStatsLog = new AtomicLong(); private volatile int collisionRuleState; + private volatile int inputReach; public MantleObjectComponent(EngineMantle engineMantle) { super(engineMantle, ReservedFlag.OBJECT, 2); @@ -134,7 +134,8 @@ public class MantleObjectComponent extends IrisMantleComponent { @Override public int getInputRadius() { - return calculateInputRadius(getRadius(), hasCollisionRules()); + int radius = getRadius(); + return calculateInputRadius(radius, inputReach, hasCollisionRules()); } @Override @@ -297,11 +298,11 @@ public class MantleObjectComponent extends IrisMantleComponent { return inputRadius >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) inputRadius; } - static int calculateInputRadius(int radius, boolean sourceAnchoredCollisions) { + static int calculateInputRadius(int radius, int inputReach, boolean sourceAnchoredCollisions) { int normalizedRadius = Math.max(0, radius); long sourceChunkRadius = sourceChunkRadius(normalizedRadius); long sourceLegs = sourceAnchoredCollisions ? 2L : 1L; - long inputRadius = (sourceChunkRadius * 16L * sourceLegs) + normalizedRadius + 1L; + long inputRadius = (sourceChunkRadius * 16L * sourceLegs) + Math.max(normalizedRadius, inputReach) + 1L; return inputRadius >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) inputRadius; } @@ -1370,7 +1371,7 @@ public class MantleObjectComponent extends IrisMantleComponent { int id = rng.i(0, Integer.MAX_VALUE); IrisObjectPlacement placement = objectPlacement.toPlacement(v.getLoadKey()); placement.setMode(ObjectPlaceMode.CENTER_HEIGHT); - placement.setRotation(buildUpsideDownRotation()); + placement.setRotation(upsideDownRotation()); placement.setCarvingSupport(CarvingMode.ANYWHERE); if (forcePlace) { placement.setForcePlace(true); @@ -1408,7 +1409,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } } - private IrisObjectRotation buildUpsideDownRotation() { + private static IrisObjectRotation upsideDownRotation() { IrisObjectRotation rt = new IrisObjectRotation(); rt.setEnabled(true); rt.setXAxis(new IrisAxisRotationClamp(true, true, 180D, 180D, 90D)); @@ -1785,135 +1786,13 @@ public class MantleObjectComponent extends IrisMantleComponent { protected int computeRadius() { KSet warnedLargeObjects = new KSet<>(); - int radius = computeDimensionRadius(getDimension(), this::getData, warnedLargeObjects); + PlacementReach reach = new ReachScan(getDimension(), this::getData, warnedLargeObjects).dimension(); UpperDimensionContext upperContext = activeUpperContext(); if (upperContext != null) { - radius = Math.max(radius, computeDimensionRadius( - upperContext.getDimension(), - upperContext, - warnedLargeObjects - )); + reach = reach.union(new ReachScan(upperContext.getDimension(), upperContext, warnedLargeObjects).upperDimension()); } - return radius; - } - - private int computeDimensionRadius( - IrisDimension dimension, - DataProvider dataProvider, - KSet warnedLargeObjects - ) { - KMap sizeCache = new KMap<>(); - int radius = 0; - for (IrisRegion region : dimension.getAllRegions(dataProvider)) { - if (region == null) { - continue; - } - radius = Math.max(radius, computePlacementRadius( - region.getObjects(), - dimension, - dataProvider, - sizeCache, - warnedLargeObjects - )); - radius = Math.max(radius, computeProceduralRadius(region.getProceduralObjects(), dimension, dataProvider)); - } - for (IrisBiome biome : dimension.getReachableBiomes(dataProvider)) { - if (biome == null) { - continue; - } - radius = Math.max(radius, computePlacementRadius( - biome.getObjects(), - dimension, - dataProvider, - sizeCache, - warnedLargeObjects - )); - radius = Math.max(radius, computeProceduralRadius(biome.getProceduralObjects(), dimension, dataProvider)); - } - return radius; - } - - private int computePlacementRadius( - KList placements, - IrisDimension dimension, - DataProvider dataProvider, - KMap sizeCache, - KSet warnedLargeObjects - ) { - int radius = 0; - for (IrisObjectPlacement placement : placements) { - if (placement == null) { - continue; - } - for (String objectKey : placement.getPlace()) { - try { - IrisBlockVector size = loadObjectSize(dataProvider, sizeCache, objectKey); - if (size == null) { - continue; - } - int reach = calculatePlacementReach(size, placement, placement.getMaximumScale(dimension)); - if (reach > 128 && warnedLargeObjects.add(objectKey)) { - IrisLogging.warn("Object " + objectKey + " has a large placement reach (" + reach + " blocks) and may increase memory usage!"); - } - radius = Math.max(radius, reach); - } catch (Throwable e) { - GenerationFailures.rethrowEngineFailure(e); - IrisLogging.reportError(e); - } - } - } - return radius; - } - - private int computeProceduralRadius(IrisProceduralObjects procedural, IrisDimension dimension, DataProvider dataProvider) { - if (procedural == null || procedural.isEmpty()) { - return 0; - } - int radius = 0; - for (IrisProceduralPlacement placement : procedural.getAllPlacements()) { - if (placement == null) { - continue; - } - KList variants = placement.getVariantObjects(dataProvider.getData()); - if (variants == null) { - continue; - } - IrisObjectPlacement objectPlacement = placement.asPlacement(); - for (IrisObject variant : variants) { - if (variant == null) { - continue; - } - IrisBlockVector size = new IrisBlockVector(variant.getW(), variant.getH(), variant.getD()); - radius = Math.max(radius, calculatePlacementReach(size, objectPlacement, objectPlacement.getMaximumScale(dimension))); - } - } - return radius; - } - - static int calculatePlacementReach(IrisBlockVector size, IrisObjectPlacement placement, double scale) { - if (size == null) { - return 0; - } - if (placement == null) { - return Math.max(Math.abs(size.getBlockX()), Math.abs(size.getBlockZ())); - } - - int width = scaledDimension(size.getBlockX(), scale); - int height = scaledDimension(size.getBlockY(), scale); - int depth = scaledDimension(size.getBlockZ(), scale); - IrisObjectRotation rotation = placement.getRotation(); - boolean rotateX = rotation != null && rotation.isEnabled() && rotation.getXAxis() != null && rotation.getXAxis().isEnabled(); - boolean rotateZ = rotation != null && rotation.isEnabled() && rotation.getZAxis() != null && rotation.getZAxis().isEnabled(); - int footprint = rotateX || rotateZ ? Math.max(width, Math.max(height, depth)) : Math.max(width, depth); - int translation = calculateTranslationReach(placement.getTranslate(), rotation, rotateX || rotateZ); - int warp = placement.getWarp() != null && !placement.getWarp().isFlat() - ? (int) Math.ceil(Math.abs(placement.getWarp().getMultiplier()) / 2D) - : 0; - int vacuum = IrisObjectVacuum.isVacuumMode(placement.getMode()) - ? 2 * IrisObjectVacuum.resolveRadius(placement.getMode(), placement.getVacuumSettings()) - : 0; - long reach = (long) footprint + translation + warp + vacuum; - return reach > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) reach; + inputReach = reach.input(); + return reach.horizontal(); } static boolean allowsObjectPlacement( @@ -1926,9 +1805,7 @@ public class MantleObjectComponent extends IrisMantleComponent { if (complex == null || object == null) { return true; } - int reach = calculatePlacementReach( - new IrisBlockVector(object.getW(), object.getH(), object.getD()), - placement, 1D); + int reach = transitionFootprint(object, placement); return complex.allowsNewGenerationFootprint( saturatedOffset(anchorX, -reach), saturatedOffset(anchorZ, -reach), @@ -1936,20 +1813,34 @@ public class MantleObjectComponent extends IrisMantleComponent { saturatedOffset(anchorZ, reach)); } + private static int transitionFootprint(IrisObject object, IrisObjectPlacement placement) { + int width = Math.abs(object.getW()); + int height = Math.abs(object.getH()); + int depth = Math.abs(object.getD()); + if (placement == null) { + return Math.max(width, depth); + } + IrisObjectRotation rotation = placement.getRotation(); + boolean rotateX = rotation != null && rotation.isEnabled() && rotation.getXAxis() != null && rotation.getXAxis().isEnabled(); + boolean rotateZ = rotation != null && rotation.isEnabled() && rotation.getZAxis() != null && rotation.getZAxis().isEnabled(); + int footprint = rotateX || rotateZ ? Math.max(width, Math.max(height, depth)) : Math.max(width, depth); + int translation = transitionTranslation(placement.getTranslate(), rotation, rotateX || rotateZ); + int warp = placement.getWarp() != null && !placement.getWarp().isFlat() + ? (int) Math.ceil(Math.abs(placement.getWarp().getMultiplier()) / 2D) + : 0; + int vacuum = IrisObjectVacuum.isVacuumMode(placement.getMode()) + ? 2 * IrisObjectVacuum.resolveRadius(placement.getMode(), placement.getVacuumSettings()) + : 0; + long reach = (long) footprint + translation + warp + vacuum; + return reach > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) reach; + } + private static int saturatedOffset(int coordinate, int offset) { long result = (long) coordinate + offset; return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, result)); } - private static int scaledDimension(int dimension, double scale) { - int absoluteDimension = Math.abs(dimension); - if (scale == 1D) { - return absoluteDimension; - } - return (int) Math.ceil(absoluteDimension * scale); - } - - private static int calculateTranslationReach(IrisObjectTranslate translate, IrisObjectRotation rotation, boolean rotateVertically) { + private static int transitionTranslation(IrisObjectTranslate translate, IrisObjectRotation rotation, boolean rotateVertically) { if (translate == null || !translate.canTranslate()) { return 0; } @@ -1963,22 +1854,6 @@ public class MantleObjectComponent extends IrisMantleComponent { return rotateY ? (int) Math.ceil(Math.hypot(x, z)) : (int) Math.max(Math.abs(x), Math.abs(z)); } - private IrisBlockVector loadObjectSize( - DataProvider dataProvider, - KMap sizeCache, - String objectKey - ) { - return sizeCache.computeIfAbsent(objectKey, k -> { - try { - return IrisObject.sampleSize(dataProvider.getData().getObjectLoader().findFile(objectKey)); - } catch (IOException e) { - IrisLogging.reportError(e); - } - - return null; - }); - } - private UpperDimensionContext activeUpperContext() { if (!getDimension().isUpperDimensionObjects()) { return null; @@ -2047,6 +1922,187 @@ public class MantleObjectComponent extends IrisMantleComponent { } } + private static final class ReachScan { + private final IrisDimension dimension; + private final DataProvider dataProvider; + private final KSet warnedLargeObjects; + private final Map extents; + private final Set caveVacuumModes; + + private ReachScan(IrisDimension dimension, DataProvider dataProvider, KSet warnedLargeObjects) { + this.dimension = dimension; + this.dataProvider = dataProvider; + this.warnedLargeObjects = warnedLargeObjects; + this.extents = new HashMap<>(); + this.caveVacuumModes = caveVacuumModes(dimension, dataProvider); + } + + private PlacementReach dimension() { + PlacementReach reach = PlacementReach.NONE; + for (IrisRegion region : dimension.getAllRegions(dataProvider)) { + if (region != null) { + reach = reach.union(placements(region.getObjects())).union(procedural(region.getProceduralObjects())); + } + } + for (IrisBiome biome : dimension.getReachableBiomes(dataProvider)) { + if (biome != null) { + reach = reach.union(placements(biome.getObjects())).union(procedural(biome.getProceduralObjects())); + } + } + return reach.withReadMargin(PlacementReach.supportReadMargin(dimension.getObjectSurfaceSupportBuffer())); + } + + private PlacementReach upperDimension() { + PlacementReach reach = PlacementReach.NONE; + for (IrisRegion region : dimension.getAllRegions(dataProvider)) { + if (region != null) { + reach = reach.union(upperPlacements(region.getSurfaceObjects())); + } + } + for (IrisBiome biome : dimension.getReachableBiomes(dataProvider)) { + if (biome != null) { + reach = reach.union(upperPlacements(biome.getSurfaceObjects())); + } + } + return reach; + } + + private PlacementReach placements(KList placements) { + PlacementReach reach = PlacementReach.NONE; + if (placements == null) { + return reach; + } + for (IrisObjectPlacement placement : placements) { + if (placement != null) { + reach = reach.union(objects(placement, profiles(placement))); + } + } + return reach; + } + + private PlacementReach upperPlacements(KList placements) { + PlacementReach reach = PlacementReach.NONE; + if (placements == null) { + return reach; + } + for (IrisObjectPlacement placement : placements) { + if (placement == null) { + continue; + } + IrisObjectPlacement upper = placement.toPlacement(placement.getPlace().toArray(new String[0])); + upper.setMode(ObjectPlaceMode.CENTER_HEIGHT); + upper.setRotation(upsideDownRotation()); + upper.setCarvingSupport(CarvingMode.ANYWHERE); + reach = reach.union(objects(placement, new KList<>(PlacementReach.profile(upper)))); + } + return reach; + } + + private PlacementReach objects(IrisObjectPlacement placement, KList profiles) { + PlacementReach reach = PlacementReach.NONE; + double scale = placement.getMaximumScale(dimension); + for (String objectKey : placement.getPlace()) { + try { + PlacementReach.Extent extent = extent(objectKey); + if (extent == null) { + continue; + } + PlacementReach.Extent scaled = extent.scaled(scale); + for (PlacementReach.Profile profile : profiles) { + PlacementReach objectReach = profile.of(scaled); + if (objectReach.horizontal() > 128 && warnedLargeObjects.add(objectKey)) { + IrisLogging.warn("Object " + objectKey + " has a large placement reach (" + objectReach.horizontal() + " blocks) and may increase memory usage!"); + } + reach = reach.union(objectReach); + } + } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); + IrisLogging.reportError(e); + } + } + return reach; + } + + private PlacementReach procedural(IrisProceduralObjects procedural) { + PlacementReach reach = PlacementReach.NONE; + if (procedural == null || procedural.isEmpty()) { + return reach; + } + for (IrisProceduralPlacement placement : procedural.getAllPlacements()) { + if (placement == null) { + continue; + } + KList variants = placement.getVariantObjects(dataProvider.getData()); + if (variants == null) { + continue; + } + IrisObjectPlacement objectPlacement = placement.asPlacement(); + KList profiles = profiles(objectPlacement); + double scale = objectPlacement.getMaximumScale(dimension); + for (IrisObject variant : variants) { + if (variant == null) { + continue; + } + PlacementReach.Extent extent = PlacementReach.Extent.of(variant).scaled(scale); + for (PlacementReach.Profile profile : profiles) { + reach = reach.union(profile.of(extent)); + } + } + } + return reach; + } + + private KList profiles(IrisObjectPlacement placement) { + KList profiles = new KList<>(PlacementReach.profile(placement)); + if (placement.getMode() != ObjectPlaceMode.CENTER_HEIGHT + || placement.getCarvingSupport() == CarvingMode.SURFACE_ONLY) { + return profiles; + } + for (ObjectPlaceMode mode : caveVacuumModes) { + IrisObjectPlacement cave = placement.toPlacement(placement.getPlace().toArray(new String[0])); + cave.setMode(mode); + profiles.add(PlacementReach.profile(cave)); + } + return profiles; + } + + private PlacementReach.Extent extent(String objectKey) { + if (extents.containsKey(objectKey)) { + return extents.get(objectKey); + } + IrisObject object = dataProvider.getData().getObjectLoader().load(objectKey); + PlacementReach.Extent extent = object == null ? null : PlacementReach.Extent.of(object); + extents.put(objectKey, extent); + return extent; + } + + private static Set caveVacuumModes(IrisDimension dimension, DataProvider dataProvider) { + Set modes = EnumSet.noneOf(ObjectPlaceMode.class); + addCaveVacuumMode(modes, dimension.getCaveProfile()); + for (IrisRegion region : dimension.getAllRegions(dataProvider)) { + if (region != null) { + addCaveVacuumMode(modes, region.getCaveProfile()); + } + } + for (IrisBiome biome : dimension.getReachableBiomes(dataProvider)) { + if (biome != null) { + addCaveVacuumMode(modes, biome.getCaveProfile()); + } + } + return modes; + } + + private static void addCaveVacuumMode(Set modes, IrisCaveProfile profile) { + if (profile == null) { + return; + } + ObjectPlaceMode mode = profile.getDefaultObjectPlaceMode(); + if (mode != null && IrisObjectVacuum.isVacuumMode(mode)) { + modes.add(mode); + } + } + } + @FunctionalInterface interface SourceChunkConsumer { void accept(int chunkX, int chunkZ); diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/PlacementReach.java b/core/src/main/java/art/arcane/iris/generation/mantle/PlacementReach.java new file mode 100644 index 000000000..255557a96 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/mantle/PlacementReach.java @@ -0,0 +1,310 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.generation.mantle; + +import art.arcane.iris.generation.block.VectorMap; +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.iris.structure.object.IrisObjectPlacement; +import art.arcane.iris.structure.object.IrisObjectRotation; +import art.arcane.iris.structure.object.IrisObjectTranslate; +import art.arcane.iris.structure.object.IrisObjectVacuum; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.Vector3i; +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; + +import java.util.Objects; + +public record PlacementReach(int horizontal, int readMargin) { + public static final PlacementReach NONE = new PlacementReach(0, 0); + private static final int SPIN_PERIOD = 360; + private static final int MAXIMUM_ROTATION_KERNELS = 1024; + private static final double ROUNDING_SLACK = 1.0E-9D; + + public PlacementReach { + if (horizontal < 0 || readMargin < 0) { + throw new IllegalArgumentException("Placement reach must be non-negative: " + horizontal + "+" + readMargin); + } + } + + public static PlacementReach of(IrisObject object, IrisObjectPlacement placement, double scale) { + return of(Extent.of(object).scaled(scale), placement); + } + + public static PlacementReach of(Extent extent, IrisObjectPlacement placement) { + return profile(placement).of(extent); + } + + public static PlacementReach anchored(Extent extent, IrisObjectPlacement placement) { + return profile(placement).anchored(extent); + } + + public static Profile profile(IrisObjectPlacement placement) { + return new Profile(Objects.requireNonNull(placement, "placement")); + } + + public int input() { + return saturate((long) horizontal + readMargin); + } + + public PlacementReach union(PlacementReach other) { + int unionHorizontal = Math.max(horizontal, other.horizontal); + int unionInput = Math.max(input(), other.input()); + return new PlacementReach(unionHorizontal, unionInput - unionHorizontal); + } + + public PlacementReach withReadMargin(int minimumReadMargin) { + return minimumReadMargin <= readMargin ? this : new PlacementReach(horizontal, minimumReadMargin); + } + + public static int supportReadMargin(int surfaceSupportBuffer) { + return 1 + Math.max(0, surfaceSupportBuffer); + } + + private static int roundedBound(double maximumMagnitude) { + return saturate((long) Math.floor(maximumMagnitude + 0.5D + ROUNDING_SLACK)); + } + + private static int saturate(long value) { + return value >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0L, value); + } + + private static IrisBlockVector corner(Extent extent, int corner) { + return new IrisBlockVector( + (corner & 1) == 0 ? extent.x().minimum() : extent.x().maximum(), + (corner & 2) == 0 ? extent.y().minimum() : extent.y().maximum(), + (corner & 4) == 0 ? extent.z().minimum() : extent.z().maximum()); + } + + public static final class Profile { + private final IrisObjectRotation rotation; + private final IrisBlockVector translation; + private final boolean rotating; + private final boolean radial; + private final int[] spinsX; + private final int[] spinsY; + private final int[] spinsZ; + private final int additive; + private final int readMargin; + + private Profile(IrisObjectPlacement placement) { + IrisObjectRotation configuredRotation = placement.getRotation(); + IrisObjectTranslate translate = placement.getTranslate(); + this.rotation = configuredRotation; + this.translation = translate != null && translate.canTranslate() + ? new IrisBlockVector(translate.getX(), translate.getY(), translate.getZ()) + : null; + this.rotating = configuredRotation != null && configuredRotation.canRotate(); + this.spinsX = rotating ? distinctSpins(configuredRotation, Axis.X) : new int[]{0}; + this.spinsY = rotating ? distinctSpins(configuredRotation, Axis.Y) : new int[]{0}; + this.spinsZ = rotating ? distinctSpins(configuredRotation, Axis.Z) : new int[]{0}; + long kernels = (long) spinsX.length * spinsY.length * spinsZ.length; + this.radial = rotating && (placement.isRotateTowardsSlope() || kernels > MAXIMUM_ROTATION_KERNELS); + this.additive = saturate((long) warpReach(placement.getWarp()) + vacuumReach(placement)); + this.readMargin = supportReadMargin(placement.getSurfaceSupportBuffer()); + } + + public PlacementReach of(Extent extent) { + Objects.requireNonNull(extent, "extent"); + return new PlacementReach(saturate((long) rotatedReach(extent) + additive), readMargin); + } + + public PlacementReach anchored(Extent extent) { + Objects.requireNonNull(extent, "extent"); + long span = Math.max(extent.x().span(), extent.z().span()); + return new PlacementReach(saturate(span + additive), readMargin); + } + + private int rotatedReach(Extent extent) { + if (!rotating) { + return unrotatedReach(extent); + } + if (radial) { + return radialReach(extent); + } + double maximum = 0D; + for (int spinX : spinsX) { + for (int spinY : spinsY) { + for (int spinZ : spinsZ) { + maximum = Math.max(maximum, rotatedCornerMaximum(extent, spinX, spinY, spinZ)); + } + } + } + return roundedBound(maximum); + } + + private double rotatedCornerMaximum(Extent extent, int spinX, int spinY, int spinZ) { + IrisBlockVector offset = translation == null ? null : rotation.rotate(translation, spinX, spinY, spinZ); + double maximum = 0D; + for (int corner = 0; corner < 8; corner++) { + IrisBlockVector rotated = rotation.rotate(corner(extent, corner), spinX, spinY, spinZ); + double x = offset == null ? rotated.getX() : rotated.getX() + offset.getX(); + double z = offset == null ? rotated.getZ() : rotated.getZ() + offset.getZ(); + maximum = Math.max(maximum, Math.max(Math.abs(x), Math.abs(z))); + } + return maximum; + } + + private int unrotatedReach(Extent extent) { + double translateX = translation == null ? 0D : translation.getX(); + double translateZ = translation == null ? 0D : translation.getZ(); + double x = Math.max(Math.abs(extent.x().minimum() + translateX), Math.abs(extent.x().maximum() + translateX)); + double z = Math.max(Math.abs(extent.z().minimum() + translateZ), Math.abs(extent.z().maximum() + translateZ)); + return roundedBound(Math.max(x, z)); + } + + private int radialReach(Extent extent) { + boolean vertical = rotation.canRotateX() || rotation.canRotateZ(); + double translateX = translation == null ? 0D : translation.getX(); + double translateY = translation == null ? 0D : translation.getY(); + double translateZ = translation == null ? 0D : translation.getZ(); + double maximum = 0D; + for (int corner = 0; corner < 8; corner++) { + IrisBlockVector position = corner(extent, corner); + double x = position.getX() + translateX; + double y = vertical ? position.getY() + translateY : 0D; + double z = position.getZ() + translateZ; + maximum = Math.max(maximum, Math.sqrt((x * x) + (y * y) + (z * z))); + } + return roundedBound(maximum); + } + + private static int warpReach(IrisGeneratorStyle warp) { + if (warp == null || warp.isFlat()) { + return 0; + } + return saturate((long) Math.ceil(Math.abs(warp.getMultiplier()) / 2D)); + } + + private static int vacuumReach(IrisObjectPlacement placement) { + if (!IrisObjectVacuum.isVacuumMode(placement.getMode())) { + return 0; + } + return IrisObjectVacuum.resolveRadius(placement.getMode(), placement.getVacuumSettings()); + } + + private static int[] distinctSpins(IrisObjectRotation rotation, Axis axis) { + boolean rotates = switch (axis) { + case X -> rotation.canRotateX(); + case Y -> rotation.canRotateY(); + case Z -> rotation.canRotateZ(); + }; + if (!rotates) { + return new int[]{0}; + } + LongOpenHashSet angles = new LongOpenHashSet(); + IntArrayList spins = new IntArrayList(); + for (int spin = 0; spin < SPIN_PERIOD; spin++) { + double angle = switch (axis) { + case X -> rotation.getXRotation(spin); + case Y -> rotation.getYRotation(spin); + case Z -> rotation.getZRotation(spin); + }; + if (angles.add(Double.doubleToLongBits(angle))) { + spins.add(spin); + } + } + return spins.toIntArray(); + } + } + + private enum Axis { + X, + Y, + Z + } + + public record Extent(Span x, Span y, Span z) { + public Extent { + Objects.requireNonNull(x, "x"); + Objects.requireNonNull(y, "y"); + Objects.requireNonNull(z, "z"); + } + + public static Extent of(IrisObject object) { + Objects.requireNonNull(object, "object"); + Vector3i center = object.getCenter(); + int[] keys = keyBounds(object.getBlocks()); + return new Extent( + Span.of(object.getW(), center.getBlockX(), keys[0], keys[3]), + Span.of(object.getH(), center.getBlockY(), keys[1], keys[4]), + Span.of(object.getD(), center.getBlockZ(), keys[2], keys[5])); + } + + public Extent scaled(double scale) { + if (scale == 1D) { + return this; + } + return new Extent(x.scaled(scale), y.scaled(scale), z.scaled(scale)); + } + + private static int[] keyBounds(VectorMap blocks) { + int[] bounds = {Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, + Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE}; + VectorMap.Cursor cursor = blocks.cursor(); + while (cursor.next()) { + IrisBlockVector key = cursor.key(); + bounds[0] = Math.min(bounds[0], key.getBlockX()); + bounds[1] = Math.min(bounds[1], key.getBlockY()); + bounds[2] = Math.min(bounds[2], key.getBlockZ()); + bounds[3] = Math.max(bounds[3], key.getBlockX()); + bounds[4] = Math.max(bounds[4], key.getBlockY()); + bounds[5] = Math.max(bounds[5], key.getBlockZ()); + } + return bounds; + } + } + + public record Span(int size, int center, int minimum, int maximum, boolean framed) { + public Span { + if (minimum > -center || maximum < size - 1 - center) { + throw new IllegalArgumentException("Span must cover the object frame: " + minimum + ".." + maximum + + " for size " + size + " centered at " + center); + } + } + + public static Span of(int size, int center, int keyMinimum, int keyMaximum) { + int frameMinimum = -center; + int frameMaximum = size - 1 - center; + boolean framed = keyMinimum >= frameMinimum && keyMaximum <= frameMaximum; + return new Span(size, center, Math.min(keyMinimum, frameMinimum), Math.max(keyMaximum, frameMaximum), framed); + } + + public int span() { + return maximum - minimum; + } + + public Span scaled(double scale) { + int scaledSize = (int) Math.ceil(size * scale); + int scaledCenter = scaledSize / 2; + int frameMinimum = -scaledCenter; + int frameMaximum = scaledSize - 1 - scaledCenter; + if (framed) { + return new Span(scaledSize, scaledCenter, frameMinimum, frameMaximum, true); + } + int scaledMinimum = (int) Math.floor((minimum + center) * scale) - scaledCenter; + double centerExcess = Math.max(0D, center - (size / 2D)); + int scaledMaximum = (int) Math.ceil(((maximum + 1 + centerExcess) * scale) + 0.5D + ROUNDING_SLACK) - 1; + return new Span(scaledSize, scaledCenter, Math.min(scaledMinimum, frameMinimum), + Math.max(scaledMaximum, frameMaximum), false); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponentBoundaryRadiusTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponentBoundaryRadiusTest.java index 8f65ca73a..56dc96eaa 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponentBoundaryRadiusTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponentBoundaryRadiusTest.java @@ -31,11 +31,6 @@ import art.arcane.iris.testsupport.PlatformBinding; import art.arcane.volmlib.util.collection.KList; import org.junit.Rule; import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.DataOutputStream; -import java.io.File; -import java.io.FileOutputStream; import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.mock; @@ -45,22 +40,12 @@ public class MantleFloatingObjectComponentBoundaryRadiusTest { @Rule public final PlatformBinding platform = PlatformBinding.mockPlatform(); - @Rule - public TemporaryFolder temporaryFolder = new TemporaryFolder(); - @Test @SuppressWarnings("unchecked") - public void transformedFloatingPlacementExpandsOwnerRadius() throws Exception { - File objectFile = temporaryFolder.newFile("floating.iob"); - try (DataOutputStream output = new DataOutputStream(new FileOutputStream(objectFile))) { - output.writeInt(1); - output.writeInt(1); - output.writeInt(1); - } - + public void transformedFloatingPlacementExpandsOwnerRadius() { IrisObjectPlacement placement = new IrisObjectPlacement() .setPlace(new KList<>("test/floating")) - .setTranslate(new IrisObjectTranslate().setX(32)); + .setTranslate(new IrisObjectTranslate().setX(-32)); IrisFloatingChildBiomes floatingChild = new IrisFloatingChildBiomes() .setFloatingObjects(new KList<>(placement)); IrisBiome biome = new IrisBiome().setFloatingChildBiomes(new KList<>(floatingChild)); @@ -69,7 +54,7 @@ public class MantleFloatingObjectComponentBoundaryRadiusTest { when(dimension.getReachableBiomes(org.mockito.ArgumentMatchers.any())).thenReturn(new KList<>(biome)); ResourceLoader objectLoader = mock(ResourceLoader.class); - when(objectLoader.findFile("test/floating")).thenReturn(objectFile); + when(objectLoader.load("test/floating")).thenReturn(new IrisObject(1, 1, 1)); IrisData data = mock(IrisData.class); when(data.getObjectLoader()).thenReturn(objectLoader); @@ -79,8 +64,8 @@ public class MantleFloatingObjectComponentBoundaryRadiusTest { when(engineMantle.getEngine()).thenReturn(engine); when(engineMantle.getData()).thenReturn(data); - assertEquals(33, new MantleFloatingObjectComponent(engineMantle).getRadius()); + assertEquals(32, new MantleFloatingObjectComponent(engineMantle).getRadius()); when(dimension.getAllObjectScaleFactor()).thenReturn(2D); - assertEquals(34, new MantleFloatingObjectComponent(engineMantle).getRadius()); + assertEquals(33, new MantleFloatingObjectComponent(engineMantle).getRadius()); } } diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentBoundaryRadiusTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentBoundaryRadiusTest.java index 09467ff87..9e374d6f8 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentBoundaryRadiusTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentBoundaryRadiusTest.java @@ -24,7 +24,6 @@ import art.arcane.iris.spi.IrisPlatforms; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.testsupport.PlatformLeakGuard; -import art.arcane.iris.generation.geometry.IrisBlockVector; import art.arcane.iris.generation.context.ChunkContext; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.mantle.runtime.Mantle; @@ -33,13 +32,8 @@ import art.arcane.volmlib.util.matter.Matter; import org.junit.After; import org.junit.Before; import org.junit.ClassRule; -import org.junit.Rule; import org.junit.Test; -import org.junit.rules.TemporaryFolder; -import java.io.DataOutputStream; -import java.io.File; -import java.io.FileOutputStream; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; @@ -60,9 +54,6 @@ public class MantleObjectComponentBoundaryRadiusTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - @Rule - public TemporaryFolder temporaryFolder = new TemporaryFolder(); - @Before public void bindPlatform() { IrisPlatforms.unbind(); @@ -92,7 +83,7 @@ public class MantleObjectComponentBoundaryRadiusTest { .setRotation(IrisObjectRotation.of(90, 0, 0)) .setTranslate(new IrisObjectTranslate().setX(3).setY(4)); - assertEquals(53, MantleObjectComponent.calculatePlacementReach(new IrisBlockVector(5, 48, 7), placement, 1D)); + assertEquals(27, PlacementReach.of(new IrisObject(5, 48, 7), placement, 1D).horizontal()); } @Test @@ -103,7 +94,7 @@ public class MantleObjectComponentBoundaryRadiusTest { .setMode(ObjectPlaceMode.VACUUM) .setVacuumSettings(new IrisVacuumSettings().setRadius(12)); - assertEquals(39, MantleObjectComponent.calculatePlacementReach(new IrisBlockVector(5, 4, 3), placement, placement.getMaximumScale(null))); + assertEquals(22, PlacementReach.of(new IrisObject(5, 4, 3), placement, placement.getMaximumScale(null)).horizontal()); } @Test @@ -126,15 +117,12 @@ public class MantleObjectComponentBoundaryRadiusTest { public void inheritedRadiusMatchesScaledFrameAndExplicitScaleOverridesIt() { IrisDimension dimension = new IrisDimension().setAllObjectScaleFactor(2D); IrisObjectPlacement inherited = new IrisObjectPlacement(); - IrisBlockVector size = new IrisBlockVector(8, 8, 8); - assertEquals(16, MantleObjectComponent.calculatePlacementReach(size, inherited, - inherited.getMaximumScale(dimension))); + IrisObject object = new IrisObject(8, 8, 8); + assertEquals(8, PlacementReach.of(object, inherited, inherited.getMaximumScale(dimension)).horizontal()); dimension.setAllObjectScaleFactor(0.5D); - assertEquals(4, MantleObjectComponent.calculatePlacementReach(size, inherited, - inherited.getMaximumScale(dimension))); + assertEquals(2, PlacementReach.of(object, inherited, inherited.getMaximumScale(dimension)).horizontal()); inherited.setScale(new IrisObjectScale().setSize(1D)); - assertEquals(8, MantleObjectComponent.calculatePlacementReach(size, inherited, - inherited.getMaximumScale(dimension))); + assertEquals(4, PlacementReach.of(object, inherited, inherited.getMaximumScale(dimension)).horizontal()); } @Test @@ -183,10 +171,11 @@ public class MantleObjectComponentBoundaryRadiusTest { @Test public void collisionInputRadiusCoversBothSourceTraversalLegs() { - assertEquals(82, MantleObjectComponent.calculateInputRadius(33, false)); - assertEquals(130, MantleObjectComponent.calculateInputRadius(33, true)); + assertEquals(84, MantleObjectComponent.calculateInputRadius(33, 35, false)); + assertEquals(132, MantleObjectComponent.calculateInputRadius(33, 35, true)); + assertEquals(82, MantleObjectComponent.calculateInputRadius(33, 0, false)); assertEquals(Integer.MAX_VALUE, - MantleObjectComponent.calculateInputRadius(Integer.MAX_VALUE, true)); + MantleObjectComponent.calculateInputRadius(Integer.MAX_VALUE, Integer.MAX_VALUE, true)); } @Test @@ -199,7 +188,7 @@ public class MantleObjectComponentBoundaryRadiusTest { for (int radius = 0; radius < 300; radius++) { for (boolean collisions : new boolean[]{false, true}) { assertTrue(MantleObjectComponent.calculateEagerInputRadius(radius, collisions) - <= MantleObjectComponent.calculateInputRadius(radius, collisions)); + <= MantleObjectComponent.calculateInputRadius(radius, radius, collisions)); } } assertEquals(Integer.MAX_VALUE, @@ -231,27 +220,25 @@ public class MantleObjectComponentBoundaryRadiusTest { when(engineMantle.getEngine()).thenReturn(engine); when(engineMantle.getData()).thenReturn(data); - assertEquals(53, new MantleObjectComponent(engineMantle).getRadius()); + assertEquals(27, new MantleObjectComponent(engineMantle).getRadius()); } @Test + @SuppressWarnings("unchecked") public void upperDimensionObjectsDriveRadiusAndCollisionMode() { IrisObjectPlacement placement = new IrisObjectPlacement() + .setPlace(new KList<>("upper/tree")) .setRotation(IrisObjectRotation.of(90, 0, 0)) .setTranslate(new IrisObjectTranslate().setX(3).setY(4)); placement.getForbiddenCollisions().add("upper/blocker"); IrisData baseData = mock(IrisData.class); IrisData upperData = mock(IrisData.class); - IrisProceduralPlacement proceduralPlacement = mock(IrisProceduralPlacement.class); - when(proceduralPlacement.getVariantObjects(upperData)) - .thenReturn(new KList<>(new IrisObject(5, 48, 7))); - when(proceduralPlacement.asPlacement()).thenReturn(placement); - IrisProceduralObjects proceduralObjects = mock(IrisProceduralObjects.class); - when(proceduralObjects.isEmpty()).thenReturn(false); - when(proceduralObjects.getAllPlacements()).thenReturn(new KList<>(proceduralPlacement)); + ResourceLoader upperObjects = mock(ResourceLoader.class); + when(upperObjects.load("upper/tree")).thenReturn(new IrisObject(5, 48, 7)); + when(upperData.getObjectLoader()).thenReturn(upperObjects); IrisRegion upperRegion = mock(IrisRegion.class); - when(upperRegion.getObjects()).thenReturn(new KList<>()); - when(upperRegion.getProceduralObjects()).thenReturn(proceduralObjects); + when(upperRegion.getObjects()).thenReturn(new KList<>(placement)); + when(upperRegion.getSurfaceObjects()).thenReturn(new KList<>(placement)); IrisDimension baseDimension = mock(IrisDimension.class); when(baseDimension.getAllObjectScaleFactor()).thenReturn(1D); when(baseDimension.isUpperDimensionObjects()).thenReturn(true); @@ -273,20 +260,13 @@ public class MantleObjectComponentBoundaryRadiusTest { MantleObjectComponent component = new MantleObjectComponent(engineMantle); - assertEquals(53, component.getRadius()); - assertEquals(182, component.getInputRadius()); + assertEquals(5, component.getRadius()); + assertEquals(41, component.getInputRadius()); } @Test @SuppressWarnings("unchecked") - public void translatedTreeCachesDistantSourcesAndSchedulesOnlyItsDestination() throws Exception { - File objectFile = temporaryFolder.newFile("tree.iob"); - try (DataOutputStream output = new DataOutputStream(new FileOutputStream(objectFile))) { - output.writeInt(1); - output.writeInt(1); - output.writeInt(1); - } - + public void translatedTreeCachesDistantSourcesAndSchedulesOnlyItsDestination() { IrisObjectPlacement placement = new IrisObjectPlacement() .setPlace(new KList<>("test/tree")) .setTranslate(new IrisObjectTranslate().setX(32)); @@ -298,7 +278,7 @@ public class MantleObjectComponentBoundaryRadiusTest { when(dimension.getReachableBiomes(any())).thenReturn(new KList<>(biome)); ResourceLoader objectLoader = mock(ResourceLoader.class); - when(objectLoader.findFile("test/tree")).thenReturn(objectFile); + when(objectLoader.load("test/tree")).thenReturn(new IrisObject(1, 1, 1)); IrisData data = mock(IrisData.class); when(data.getObjectLoader()).thenReturn(objectLoader); @@ -310,9 +290,9 @@ public class MantleObjectComponentBoundaryRadiusTest { when(engineMantle.getData()).thenReturn(data); MantleObjectComponent component = spy(new MantleObjectComponent(engineMantle)); - assertEquals(33, component.getRadius()); + assertEquals(32, component.getRadius()); assertEquals(0, component.getOutputRadius()); - assertEquals(82, component.getInputRadius()); + assertEquals(68, component.getInputRadius()); Mantle mantle = mock(Mantle.class); MantleChunk chunk = mock(MantleChunk.class); @@ -341,9 +321,9 @@ public class MantleObjectComponentBoundaryRadiusTest { component.generateLayer(directWriter, 0, 0, mock(ChunkContext.class)); - assertEquals(49, directOrigins.get()); + assertEquals(25, directOrigins.get()); component.generateLayer(directWriter, 0, 0, mock(ChunkContext.class)); - assertEquals(49, directOrigins.get()); + assertEquals(25, directOrigins.get()); List generatedChunks = new ArrayList<>(); doAnswer(invocation -> { @@ -370,14 +350,14 @@ public class MantleObjectComponentBoundaryRadiusTest { component.getRadius(), (chunkX, chunkZ) -> replayedSources.add(chunkX + "," + chunkZ) ); - assertEquals(49, replayedSources.size()); - assertEquals("-3,-3", replayedSources.getFirst()); - assertEquals("-3,-2", replayedSources.get(1)); - assertEquals("3,3", replayedSources.getLast()); + assertEquals(25, replayedSources.size()); + assertEquals("-2,-2", replayedSources.getFirst()); + assertEquals("-2,-1", replayedSources.get(1)); + assertEquals("2,2", replayedSources.getLast()); placement.getForbiddenCollisions().add("test/tree"); component.hotload(); - assertEquals(130, component.getInputRadius()); + assertEquals(100, component.getInputRadius()); } private static final class TestMatterGenerator implements MatterGenerator { diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/PlacementReachPackTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/PlacementReachPackTest.java new file mode 100644 index 000000000..6ee5a3425 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/mantle/PlacementReachPackTest.java @@ -0,0 +1,517 @@ +package art.arcane.iris.generation.mantle; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisFloatingChildBiomes; +import art.arcane.iris.generation.decoration.IrisProceduralObjects; +import art.arcane.iris.generation.decoration.IrisProceduralPlacement; +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.MeteredCache; +import art.arcane.iris.generation.runtime.PreservationRegistry; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.structure.object.FloatingObjectFootprint; +import art.arcane.iris.structure.object.IrisAxisRotationClamp; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.iris.structure.object.IrisObjectPlacement; +import art.arcane.iris.structure.object.IrisObjectRotation; +import art.arcane.iris.structure.object.IrisObjectScale; +import art.arcane.iris.structure.object.IrisObjectTranslate; +import art.arcane.iris.structure.object.IrisObjectPlacementScaleInterpolator; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.Vector3i; +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.mockito.Answers; + +import java.io.BufferedInputStream; +import java.io.DataInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +public class PlacementReachPackTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + private static final int SPIN_PERIOD = 360; + private static final int MAXIMUM_CHECKED_KERNELS = 4096; + private static final String V2_HEADER = "Iris V2 IOB;"; + + private File pack; + private IrisData data; + private IrisDimension dimension; + + @Before + public void loadPack() throws IOException { + String configured = System.getenv("IRIS_TEST_PACK"); + Assume.assumeTrue(configured != null && !configured.isBlank()); + pack = new File(configured).getAbsoluteFile(); + Assume.assumeTrue(new File(pack, "objects").isDirectory()); + File[] dimensions = new File(pack, "dimensions").listFiles((folder, name) -> name.endsWith(".json")); + Assume.assumeTrue(dimensions != null && dimensions.length > 0); + bindPlatform(Files.createTempDirectory("iris-placement-reach").toFile()); + data = IrisData.get(pack); + String dimensionKey = System.getenv().getOrDefault("IRIS_TEST_PACK_DIMENSION", + dimensions[0].getName().substring(0, dimensions[0].getName().length() - 5)); + dimension = data.getDimensionLoader().load(dimensionKey); + assertTrue("dimension " + dimensionKey + " must load", dimension != null); + } + + @After + public void unbindPlatform() { + IrisServices.remove(PreservationRegistry.class); + IrisPlatforms.unbind(); + } + + @Test + public void everyObjectFileStaysInsideItsReachUnderRightAngleRotations() throws IOException { + List placements = List.of( + new IrisObjectPlacement(), + new IrisObjectPlacement().setRotation(rotation(new IrisAxisRotationClamp(true, false, 0D, 0D, 90D), + new IrisAxisRotationClamp(true, false, 0D, 0D, 90D), new IrisAxisRotationClamp())), + new IrisObjectPlacement().setRotation(rotation(new IrisAxisRotationClamp(), + new IrisAxisRotationClamp(true, false, 0D, 0D, 90D), new IrisAxisRotationClamp(true, false, 0D, 0D, 90D))), + new IrisObjectPlacement().setRotation(IrisObjectRotation.of(0D, 0D, 0D)), + new IrisObjectPlacement().setRotation(rotation(new IrisAxisRotationClamp(), + new IrisAxisRotationClamp(true, false, 0D, 0D, 45D), new IrisAxisRotationClamp())) + .setTranslate(new IrisObjectTranslate().setX(7).setZ(-3))); + List files; + try (Stream walk = Files.walk(new File(pack, "objects").toPath())) { + files = walk.filter(path -> path.toString().endsWith(".iob")).sorted().toList(); + } + assertTrue(!files.isEmpty()); + Statistics statistics = new Statistics(); + NativeBlockState marker = mock(NativeBlockState.class, withSettings().stubOnly()); + for (Path file : files) { + IrisObject object = readPositions(file, marker); + for (IrisObjectPlacement placement : placements) { + verifyCentered(file.toString(), object, placement, PlacementReach.of(object, placement, 1D), statistics); + } + } + System.out.println("PLACEMENT_REACH_FILES objects=" + files.size() + " " + statistics); + } + + @Test + public void everyPackPlacementStaysInsideItsReach() { + Statistics statistics = new Statistics(); + Set placements = new LinkedHashSet<>(); + for (IrisRegion region : dimension.getAllRegions(() -> data)) { + placements.addAll(region.getObjects()); + } + for (IrisBiome biome : dimension.getReachableBiomes(() -> data)) { + placements.addAll(biome.getObjects()); + } + for (IrisObjectPlacement placement : placements) { + for (String key : placement.getPlace()) { + IrisObject object = data.getObjectLoader().load(key); + if (object == null) { + continue; + } + PlacementReach reach = PlacementReach.of(object, placement, placement.getMaximumScale(dimension)); + forEachVariant(object, placement, 1D, variant -> verifyCentered(key, variant, placement, reach, statistics)); + } + } + for (IrisProceduralPlacement procedural : proceduralPlacements()) { + KList variants = procedural.getVariantObjects(data); + if (variants == null) { + continue; + } + IrisObjectPlacement placement = procedural.asPlacement(); + for (IrisObject variant : variants) { + PlacementReach reach = PlacementReach.of(variant, placement, placement.getMaximumScale(dimension)); + forEachVariant(variant, placement, 1D, + scaled -> verifyCentered(variant.getLoadKey(), scaled, placement, reach, statistics)); + } + } + assertTrue(statistics.checked > 0); + System.out.println("PLACEMENT_REACH_PLACEMENTS placements=" + placements.size() + " " + statistics); + } + + @Test + public void floatingPlacementsStayInsideTheirReach() { + Statistics statistics = new Statistics(); + for (IrisBiome biome : dimension.getReachableBiomes(() -> data)) { + KList entries = biome.getFloatingChildBiomes(); + if (entries == null) { + continue; + } + for (IrisFloatingChildBiomes entry : entries) { + double shrink = entry.hasObjectShrink() ? entry.getShrinkScale().getMaxScale() : 1D; + IrisBiome target = entry.getRealBiome(biome, data); + for (IrisObjectPlacement placement : listOrEmpty(entry.getFloatingObjects())) { + verifyFloating(placement, shrink, false, statistics); + } + List anchored = new ArrayList<>(); + anchored.addAll(listOrEmpty(entry.getExtraObjects())); + anchored.addAll(listOrEmpty(entry.getTopObjectOverrides())); + anchored.addAll(listOrEmpty(entry.getBottomObjectOverrides())); + if (target != null) { + anchored.addAll(listOrEmpty(entry.resolveTopObjects(target))); + anchored.addAll(listOrEmpty(entry.resolveBottomObjects(target))); + } + for (IrisObjectPlacement placement : anchored) { + verifyFloating(placement, shrink, true, statistics); + } + } + } + System.out.println("PLACEMENT_REACH_FLOATING " + statistics); + } + + @Test + public void reportsTheMantleWindow() { + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + EngineMantle engineMantle = mock(EngineMantle.class); + when(engineMantle.getEngine()).thenReturn(engine); + when(engineMantle.getData()).thenReturn(data); + MantleObjectComponent objects = new MantleObjectComponent(engineMantle); + MantleFloatingObjectComponent floating = new MantleFloatingObjectComponent(engineMantle); + int objectRadius = objects.getRadius(); + int objectInput = objects.getInputRadius(); + int objectEager = objects.getEagerInputRadius(objectInput); + int floatingRadius = floating.getRadius(); + int floatingPassRadius = chunkRadius(floatingRadius); + int objectPassRadius = chunkRadius(floatingRadius); + int terrainRadius = chunkRadius(floatingRadius + objectEager + 1); + int writerRadius = 2 * chunkRadius(floatingRadius + objectInput + 1); + System.out.println("PLACEMENT_REACH_WINDOW objectReach=" + objectRadius + + " objectInput=" + objectInput + + " objectEager=" + objectEager + + " sourceChunkRadius=" + MantleObjectComponent.sourceChunkRadius(objectRadius) + + " floatingReach=" + floatingRadius + + " floatingPassRadius=" + floatingPassRadius + + " objectPassRadius=" + objectPassRadius + + " terrainRadius=" + terrainRadius + + " terrainWindow=" + ((2 * terrainRadius) + 1) + "x" + ((2 * terrainRadius) + 1) + + " writerRadius=" + writerRadius); + assertTrue(objectRadius > 0); + } + + private void verifyFloating(IrisObjectPlacement placement, double shrink, boolean anchored, Statistics statistics) { + if (placement == null || placement.getPlace() == null) { + return; + } + for (String key : placement.getPlace()) { + IrisObject object = data.getObjectLoader().load(key); + if (object == null) { + continue; + } + PlacementReach.Extent extent = PlacementReach.Extent.of(object) + .scaled(placement.getMaximumScale(dimension)).scaled(shrink); + if (!anchored) { + PlacementReach reach = PlacementReach.of(extent, placement); + forEachVariant(object, placement, shrink, variant -> verifyCentered(key, variant, placement, reach, statistics)); + continue; + } + PlacementReach reach = PlacementReach.anchored(extent, placement); + forEachVariant(object, placement, shrink, variant -> verifyAnchored(key, variant, reach, statistics)); + } + } + + private void verifyAnchored(String key, IrisObject variant, PlacementReach reach, Statistics statistics) { + FloatingObjectFootprint footprint = FloatingObjectFootprint.compute(variant); + List positions = positions(variant); + List rotations = List.of(IrisObjectRotation.of(0D, 0D, 0D), + IrisObjectRotation.xFlip180WithY(0D), IrisObjectRotation.xFlip180WithY(90D), + IrisObjectRotation.xFlip180WithY(180D), IrisObjectRotation.xFlip180WithY(270D)); + for (IrisObjectRotation rotation : rotations) { + IrisBlockVector anchor = rotation.rotate(new IrisBlockVector(footprint.getTallestKx(), 0, footprint.getTallestKz()), 0, 0, 0); + int actual = 0; + for (IrisBlockVector position : positions) { + IrisBlockVector rotated = rotation.rotate(position, 0, 0, 0); + actual = Math.max(actual, Math.abs((int) Math.round(rotated.getX()) - anchor.getBlockX())); + actual = Math.max(actual, Math.abs((int) Math.round(rotated.getZ()) - anchor.getBlockZ())); + } + record(key, actual, reach.horizontal(), statistics); + } + } + + private void verifyCentered(String key, IrisObject object, IrisObjectPlacement placement, PlacementReach reach, + Statistics statistics) { + IrisObjectRotation rotation = placement.getRotation(); + IrisObjectTranslate translate = placement.getTranslate(); + boolean translating = translate != null && translate.canTranslate(); + int[] spinsX = spins(rotation, 0); + int[] spinsY = spins(rotation, 1); + int[] spinsZ = spins(rotation, 2); + List positions = positions(object); + long kernels = (long) spinsX.length * spinsY.length * spinsZ.length; + if (kernels > MAXIMUM_CHECKED_KERNELS) { + fail("rotation for " + key + " has " + kernels + " kernels"); + } + for (int spinX : spinsX) { + for (int spinY : spinsY) { + for (int spinZ : spinsZ) { + IrisBlockVector offset = translating + ? rotation.rotate(new IrisBlockVector(translate.getX(), translate.getY(), translate.getZ()), spinX, spinY, spinZ) + : null; + int actual = 0; + for (IrisBlockVector position : positions) { + IrisBlockVector moved = rotation.rotate(position, spinX, spinY, spinZ).clone(); + if (offset != null) { + moved.add(offset); + } + actual = Math.max(actual, Math.abs((int) Math.round(moved.getX()))); + actual = Math.max(actual, Math.abs((int) Math.round(moved.getZ()))); + } + record(key, actual, reach.horizontal(), statistics); + } + } + } + } + + private static IrisObject readPositions(Path file, NativeBlockState marker) throws IOException { + try (DataInputStream input = new DataInputStream(new BufferedInputStream(Files.newInputStream(file)))) { + IrisObject object = new IrisObject(input.readInt(), input.readInt(), input.readInt()); + input.mark(Short.BYTES + V2_HEADER.length()); + int headerLength = input.readUnsignedShort(); + boolean v2 = headerLength == V2_HEADER.length() + && V2_HEADER.equals(new String(input.readNBytes(headerLength), StandardCharsets.US_ASCII)); + if (!v2) { + input.reset(); + } + int paletteSize = v2 ? input.readShort() : 0; + for (int index = 0; index < paletteSize; index++) { + input.readUTF(); + } + int blocks = input.readInt(); + for (int index = 0; index < blocks; index++) { + IrisBlockVector position = new IrisBlockVector(input.readShort(), input.readShort(), input.readShort()); + if (v2) { + input.readShort(); + } else { + input.readUTF(); + } + object.getBlocks().put(position, marker); + } + return object; + } + } + + private static void record(String key, int actual, int reach, Statistics statistics) { + statistics.checked++; + statistics.maximumActual = Math.max(statistics.maximumActual, actual); + statistics.maximumReach = Math.max(statistics.maximumReach, reach); + statistics.slack += reach - actual; + if (actual > reach) { + fail(key + " places a block " + actual + " blocks from its origin, beyond its reach " + reach); + } + } + + private static List positions(IrisObject object) { + List positions = new ArrayList<>(object.getBlocks().size() + 8); + object.getBlocks().forEach((position, state) -> positions.add(position)); + Vector3i center = object.getCenter(); + int[] xs = {-center.getBlockX(), object.getW() - center.getBlockX() - 1}; + int[] ys = {-center.getBlockY(), object.getH() - center.getBlockY() - 1}; + int[] zs = {-center.getBlockZ(), object.getD() - center.getBlockZ() - 1}; + for (int x : xs) { + for (int y : ys) { + for (int z : zs) { + positions.add(new IrisBlockVector(x, y, z)); + } + } + } + return positions; + } + + private void forEachVariant(IrisObject object, IrisObjectPlacement placement, double shrink, Consumer consumer) { + IrisObjectScale scale = placement.getScale(); + IrisObjectPlacementScaleInterpolator interpolation = scale == null || scale.getInterpolation() == null + ? IrisObjectPlacementScaleInterpolator.NONE : scale.getInterpolation(); + for (double factor : scales(placement)) { + IrisObject variant = factor == 1D && (scale == null || !scale.shouldScale()) + ? object + : scale == null ? IrisObjectScale.getFixed(object, factor) : object.scaled(factor, interpolation); + consumer.accept(shrink < 1D ? variant.scaled(shrink, IrisObjectPlacementScaleInterpolator.NONE) : variant); + } + } + + private double[] scales(IrisObjectPlacement placement) { + IrisObjectScale scale = placement.getScale(); + if (scale == null) { + return new double[]{dimension.getAllObjectScaleFactor()}; + } + if (!scale.shouldScale()) { + return new double[]{1D}; + } + if (scale.getSize() != 1D) { + return new double[]{scale.getSize()}; + } + if (scale.getMinimumScale() == scale.getMaximumScale()) { + return new double[]{scale.getMinimumScale()}; + } + int count = Math.max(1, Math.min(scale.getVariations(), 32)); + double[] scales = new double[count]; + for (int index = 0; index < count; index++) { + scales[index] = scale.getMinimumScale() + (((scale.getMaximumScale() - scale.getMinimumScale()) / count) * index); + } + return scales; + } + + private List proceduralPlacements() { + List placements = new ArrayList<>(); + for (IrisRegion region : dimension.getAllRegions(() -> data)) { + addProcedural(placements, region.getProceduralObjects()); + } + for (IrisBiome biome : dimension.getReachableBiomes(() -> data)) { + addProcedural(placements, biome.getProceduralObjects()); + } + return placements; + } + + private static void addProcedural(List placements, IrisProceduralObjects procedural) { + if (procedural != null && !procedural.isEmpty()) { + placements.addAll(procedural.getAllPlacements()); + } + } + + private static int[] spins(IrisObjectRotation rotation, int axis) { + if (rotation == null || !rotation.canRotate()) { + return new int[]{0}; + } + boolean rotates = switch (axis) { + case 0 -> rotation.canRotateX(); + case 1 -> rotation.canRotateY(); + default -> rotation.canRotateZ(); + }; + if (!rotates) { + return new int[]{0}; + } + LongOpenHashSet angles = new LongOpenHashSet(); + IntArrayList spins = new IntArrayList(); + for (int spin = -SPIN_PERIOD + 1; spin < SPIN_PERIOD; spin++) { + double angle = switch (axis) { + case 0 -> rotation.getXRotation(spin); + case 1 -> rotation.getYRotation(spin); + default -> rotation.getZRotation(spin); + }; + if (angles.add(Double.doubleToLongBits(angle))) { + spins.add(spin); + } + } + return spins.toIntArray(); + } + + private static List listOrEmpty(KList list) { + return list == null ? List.of() : list; + } + + private static IrisObjectRotation rotation(IrisAxisRotationClamp x, IrisAxisRotationClamp y, IrisAxisRotationClamp z) { + IrisObjectRotation rotation = new IrisObjectRotation(); + rotation.setEnabled(true); + rotation.setXAxis(x); + rotation.setYAxis(y); + rotation.setZAxis(z); + return rotation; + } + + private static int chunkRadius(int blockRadius) { + return blockRadius > 0 ? Math.ceilDiv(blockRadius, 16) : 0; + } + + private static void bindPlatform(File dataFolder) { + IrisPlatforms.unbind(); + Map states = new ConcurrentHashMap<>(); + PlatformRegistries registries = mock(PlatformRegistries.class, withSettings().stubOnly()); + when(registries.block(anyString())).thenAnswer(invocation -> state(states, invocation.getArgument(0))); + when(registries.blockOrNull(anyString())).thenAnswer(invocation -> state(states, invocation.getArgument(0))); + when(registries.blockOrNull(anyString(), anyBoolean())).thenAnswer(invocation -> state(states, invocation.getArgument(0))); + when(registries.air()).thenAnswer(invocation -> state(states, "minecraft:air")); + IrisPlatform platform = mock(IrisPlatform.class, Answers.CALLS_REAL_METHODS); + when(platform.dataFolder()).thenReturn(dataFolder); + when(platform.dataFile(any(String[].class))).thenAnswer(invocation -> { + Object[] path = invocation.getRawArguments(); + return new File(dataFolder, String.join(File.separator, (String[]) path[0])); + }); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + IrisServices.register(PreservationRegistry.class, new IgnoredPreservation()); + } + + private static NativeBlockState state(Map states, String key) { + return states.computeIfAbsent(key, ignored -> { + String material = materialOf(key); + boolean air = material.endsWith(":air") || material.endsWith("cave_air") || material.endsWith("void_air"); + NativeBlockState state = mock(NativeBlockState.class, withSettings().stubOnly()); + when(state.key()).thenReturn(key); + when(state.materialKey()).thenReturn(material); + when(state.isAir()).thenReturn(air); + when(state.isSolid()).thenReturn(!air); + when(state.isOccluding()).thenReturn(!air); + return state; + }); + } + + private static String materialOf(String key) { + String trimmed = key.trim().toLowerCase(Locale.ROOT); + int properties = trimmed.indexOf('['); + String head = properties < 0 ? trimmed : trimmed.substring(0, properties); + return head.indexOf(':') < 0 ? "minecraft:" + head : head; + } + + private static final class IgnoredPreservation implements PreservationRegistry { + @Override + public void register(Thread thread) { + } + + @Override + public void register(ExecutorService service) { + } + + @Override + public void registerCache(MeteredCache cache) { + } + + @Override + public void dereference() { + } + } + + private static final class Statistics { + private long checked; + private long slack; + private int maximumActual; + private int maximumReach; + + @Override + public String toString() { + return "checked=" + checked + " maxActual=" + maximumActual + " maxReach=" + maximumReach + + " meanSlack=" + (checked == 0 ? 0D : (double) slack / checked); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/PlacementReachTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/PlacementReachTest.java new file mode 100644 index 000000000..1cdb89c4a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/mantle/PlacementReachTest.java @@ -0,0 +1,407 @@ +package art.arcane.iris.generation.mantle; + +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.decoration.IrisStiltSettings; +import art.arcane.iris.generation.decoration.IrisVacuumSettings; +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.structure.object.FloatingObjectFootprint; +import art.arcane.iris.structure.object.IObjectPlacer; +import art.arcane.iris.structure.object.IrisAxisRotationClamp; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.iris.structure.object.IrisObjectPlacement; +import art.arcane.iris.structure.object.IrisObjectPlacementScaleInterpolator; +import art.arcane.iris.structure.object.IrisObjectRotation; +import art.arcane.iris.structure.object.IrisObjectScale; +import art.arcane.iris.structure.object.IrisObjectTranslate; +import art.arcane.iris.structure.object.ObjectPlaceMode; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.stream.ProceduralStream; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +public class PlacementReachTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + private static final int ORIGIN_X = 1000; + private static final int ORIGIN_Y = 90; + private static final int ORIGIN_Z = -2000; + private static final int GROUND_Y = 64; + + private final Map states = new ConcurrentHashMap<>(); + private IrisData data; + private IrisData objectData; + private Engine engine; + + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformRegistries registries = mock(PlatformRegistries.class, withSettings().stubOnly()); + when(registries.block(anyString())).thenAnswer(invocation -> state(invocation.getArgument(0))); + when(registries.blockOrNull(anyString())).thenAnswer(invocation -> state(invocation.getArgument(0))); + when(registries.blockOrNull(anyString(), anyBoolean())).thenAnswer(invocation -> state(invocation.getArgument(0))); + when(registries.air()).thenAnswer(invocation -> state("minecraft:air")); + IrisPlatform platform = mock(IrisPlatform.class, withSettings().stubOnly()); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + + ProceduralStream rock = mock(ProceduralStream.class, withSettings().stubOnly()); + IrisComplex complex = mock(IrisComplex.class, withSettings().stubOnly()); + when(complex.getRockStream()).thenReturn(rock); + engine = mock(Engine.class, withSettings().stubOnly()); + when(engine.getHeight()).thenReturn(256); + when(engine.getMinHeight()).thenReturn(0); + when(engine.getComplex()).thenReturn(complex); + data = mock(IrisData.class, withSettings().stubOnly()); + when(data.getEngine()).thenReturn(engine); + objectData = mock(IrisData.class, withSettings().stubOnly()); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void halfExtentReachIsTheCenteredFrameNotTheFullWidth() { + IrisObject object = singleBlockObject(103, 75, 73); + IrisObjectPlacement placement = new IrisObjectPlacement(); + + assertEquals(51, PlacementReach.of(object, placement, 1D).horizontal()); + assertEquals(54, PlacementReach.of(object, placement, 1D).input()); + assertEquals(51, PlacementReach.of(object, placement.setRotation(IrisObjectRotation.of(0D, 0D, 0D)), 1D).horizontal()); + } + + @Test + public void arbitraryAngleRotationUsesTheRotatedFrame() { + IrisObject object = singleBlockObject(21, 5, 21); + IrisObjectPlacement placement = new IrisObjectPlacement().setRotation(rotation(new IrisAxisRotationClamp(), + new IrisAxisRotationClamp(true, false, 0D, 0D, 45D), new IrisAxisRotationClamp())); + + assertEquals(14, PlacementReach.of(object, placement, 1D).horizontal()); + } + + @Test + public void unionKeepsTheLargestReachAndTheLargestInput() { + PlacementReach union = new PlacementReach(10, 2).union(new PlacementReach(4, 12)); + + assertEquals(10, union.horizontal()); + assertEquals(16, union.input()); + assertEquals(19, union.withReadMargin(9).input()); + assertEquals(16, union.withReadMargin(1).input()); + } + + @Test + public void scaledSpanBoundsEveryScaledVariant() { + Random random = new Random(91L); + double[] scales = {0.01D, 0.25D, 0.45D, 0.5D, 0.75D, 0.95D, 1D, 1.3D, 1.5D, 2D, 2.6D, 3.7D, 4D}; + for (int trial = 0; trial < 120; trial++) { + int outside = trial % 3 == 0 ? 1 + random.nextInt(3) : 0; + IrisObject object = randomObject(random, outside); + PlacementReach.Extent extent = PlacementReach.Extent.of(object); + for (double scale : scales) { + for (IrisObjectPlacementScaleInterpolator interpolation : List.of(IrisObjectPlacementScaleInterpolator.NONE, + IrisObjectPlacementScaleInterpolator.TRILINEAR)) { + if (interpolation != IrisObjectPlacementScaleInterpolator.NONE && scale > 2D) { + continue; + } + IrisObject scaled = object.scaled(scale, interpolation); + for (double maximum : new double[]{scale, Math.min(IrisObjectScale.MAXIMUM_FACTOR, scale * 1.4D)}) { + assertContains(extent.scaled(maximum), scaled, "scale " + scale + " bounded at " + maximum); + for (double shrink : new double[]{0.45D, 0.8D}) { + assertContains(extent.scaled(maximum).scaled(shrink), + scaled.scaled(shrink, IrisObjectPlacementScaleInterpolator.NONE), + "scale " + scale + " shrink " + shrink); + } + } + } + } + } + } + + @Test + public void runnerWritesAndReadsStayInsideTheReach() { + Random random = new Random(1337L); + for (int trial = 0; trial < 260; trial++) { + IrisObject object = randomObject(random, trial % 5 == 0 ? 2 : 0); + object.setLoader(objectData); + IrisObjectPlacement placement = randomPlacement(random); + PlacementReach reach = PlacementReach.of(object, placement, placement.getMaximumScale(null)); + for (int seed = 0; seed < 12; seed++) { + RNG rng = new RNG((trial * 7919L) + seed); + IrisObject variant = placement.getScale() == null ? object : placement.getScale().get(rng, object); + RecordingPlacer placer = new RecordingPlacer(engine); + variant.place(ORIGIN_X, ORIGIN_Y, ORIGIN_Z, placer, placement, rng, null, null, data); + String context = "trial " + trial + " seed " + seed + " mode " + placement.getMode() + + " rotation " + placement.getRotation() + " translate " + placement.getTranslate() + + " scale " + placement.getScale() + " warp " + placement.getWarp().getMultiplier(); + assertTrue(context + " writes " + placer.writeReach + " > " + reach.horizontal(), + placer.writeReach <= reach.horizontal()); + assertTrue(context + " reads " + placer.readReach + " > " + reach.input(), + placer.readReach <= reach.input()); + } + } + } + + @Test + public void anchoredReachCoversTheFloatingFootprintAnchor() { + Random random = new Random(7L); + for (int trial = 0; trial < 80; trial++) { + IrisObject object = randomObject(random, trial % 4 == 0 ? 1 : 0); + PlacementReach reach = PlacementReach.anchored(PlacementReach.Extent.of(object), new IrisObjectPlacement()); + FloatingObjectFootprint footprint = FloatingObjectFootprint.compute(object); + for (IrisObjectRotation rotation : List.of(IrisObjectRotation.of(0D, 0D, 0D), + IrisObjectRotation.xFlip180WithY(0D), IrisObjectRotation.xFlip180WithY(90D), + IrisObjectRotation.xFlip180WithY(180D), IrisObjectRotation.xFlip180WithY(270D))) { + IrisBlockVector anchor = rotation.rotate(new IrisBlockVector(footprint.getTallestKx(), 0, footprint.getTallestKz()), 0, 0, 0); + object.getBlocks().forEach((position, state) -> { + IrisBlockVector rotated = rotation.rotate(position, 0, 0, 0); + assertTrue(Math.abs((int) Math.round(rotated.getX()) - anchor.getBlockX()) <= reach.horizontal()); + assertTrue(Math.abs((int) Math.round(rotated.getZ()) - anchor.getBlockZ()) <= reach.horizontal()); + }); + } + } + } + + private IrisObjectPlacement randomPlacement(Random random) { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setRotation(randomRotation(random)); + if (random.nextInt(3) > 0) { + placement.setTranslate(new IrisObjectTranslate() + .setX(random.nextInt(41) - 20) + .setY(random.nextInt(21) - 10) + .setZ(random.nextInt(41) - 20)); + } + if (random.nextInt(3) == 0) { + placement.setWarp(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).setMultiplier(1D + random.nextInt(9))); + } + List modes = List.of(ObjectPlaceMode.CENTER_HEIGHT, ObjectPlaceMode.STILT, ObjectPlaceMode.FAST_STILT, + ObjectPlaceMode.CENTER_STILT, ObjectPlaceMode.ERODE_STILT, ObjectPlaceMode.ORGANIC_STILT, ObjectPlaceMode.CEILING_HANG, + ObjectPlaceMode.VACUUM, ObjectPlaceMode.VACUUM_HIGH, ObjectPlaceMode.VACUUM_FAST, ObjectPlaceMode.VACUUM_ORGANIC, + ObjectPlaceMode.VACUUM_WAVY, ObjectPlaceMode.STRUCTURE_PIECE); + placement.setMode(modes.get(random.nextInt(modes.size()))); + if (random.nextInt(4) == 0) { + placement.setVacuumSettings(new IrisVacuumSettings().setRadius(1 + random.nextInt(16))); + } + if (random.nextInt(4) == 0) { + placement.setStiltSettings(new IrisStiltSettings()); + } + placement.setBore(random.nextInt(4) == 0); + placement.setBoreExtendMaxY(random.nextInt(3)); + placement.setSnow(random.nextInt(4) == 0 ? 0.6D : 0D); + placement.setSurfaceSupportBuffer(random.nextInt(5)); + switch (random.nextInt(5)) { + case 0 -> placement.setScale(new IrisObjectScale().setSize(0.3D + (random.nextDouble() * 1.6D))); + case 1 -> placement.setScale(new IrisObjectScale().setMinimumScale(0.5D).setMaximumScale(2.3D) + .setVariations(5).setInterpolation(IrisObjectPlacementScaleInterpolator.TRILINEAR)); + case 2 -> placement.setScale(new IrisObjectScale().setMinimumScale(0.4D).setMaximumScale(0.9D).setVariations(3)); + default -> { + } + } + return placement; + } + + private static IrisObjectRotation randomRotation(Random random) { + return switch (random.nextInt(9)) { + case 0 -> new IrisObjectRotation(); + case 1 -> IrisObjectRotation.of(0D, 0D, 0D); + case 2 -> rotation(new IrisAxisRotationClamp(), new IrisAxisRotationClamp(true, false, 0D, 0D, 45D), new IrisAxisRotationClamp()); + case 3 -> rotation(new IrisAxisRotationClamp(), new IrisAxisRotationClamp(true, false, 0D, 0D, 0D), new IrisAxisRotationClamp()); + case 4 -> IrisObjectRotation.of(90D, 0D, 0D); + case 5 -> rotation(new IrisAxisRotationClamp(true, false, 0D, 0D, 90D), + new IrisAxisRotationClamp(true, false, 0D, 0D, 90D), new IrisAxisRotationClamp(true, false, 0D, 0D, 90D)); + case 6 -> rotation(new IrisAxisRotationClamp(true, false, 0D, 0D, 0D), + new IrisAxisRotationClamp(true, false, 0D, 0D, 0D), new IrisAxisRotationClamp(true, false, 0D, 0D, 30D)); + case 7 -> IrisObjectRotation.of(30D, 135D, 0D); + default -> rotation(new IrisAxisRotationClamp(), new IrisAxisRotationClamp(true, false, 20D, 110D, 15D), + new IrisAxisRotationClamp(true, true, 180D, 180D, 0D)); + }; + } + + private IrisObject randomObject(Random random, int outside) { + int width = 1 + random.nextInt(18); + int height = 1 + random.nextInt(14); + int depth = 1 + random.nextInt(18); + IrisObject object = new IrisObject(width, height, depth); + int count = 1 + random.nextInt(Math.max(1, (width * height * depth) / 3)); + for (int index = 0; index < count; index++) { + int x = random.nextInt(width) - (width / 2); + int y = random.nextInt(height) - (height / 2); + int z = random.nextInt(depth) - (depth / 2); + object.getBlocks().put(new IrisBlockVector(x, y, z), state(random.nextInt(5) == 0 ? "minecraft:oak_leaves" : "minecraft:stone")); + } + for (int index = 0; index < outside; index++) { + int x = random.nextInt(width + 10) - ((width + 10) / 2); + int y = random.nextInt(height + 6) - ((height + 6) / 2); + int z = random.nextInt(depth + 10) - ((depth + 10) / 2); + object.getBlocks().put(new IrisBlockVector(x, y, z), state("minecraft:stone")); + } + return object; + } + + private IrisObject singleBlockObject(int width, int height, int depth) { + IrisObject object = new IrisObject(width, height, depth); + object.getBlocks().put(new IrisBlockVector(0, 0, 0), state("minecraft:stone")); + return object; + } + + private static void assertContains(PlacementReach.Extent extent, IrisObject object, String context) { + object.getBlocks().forEach((position, state) -> { + assertTrue(context + " x " + position.getBlockX() + " outside " + extent.x(), + position.getBlockX() >= extent.x().minimum() && position.getBlockX() <= extent.x().maximum()); + assertTrue(context + " y " + position.getBlockY() + " outside " + extent.y(), + position.getBlockY() >= extent.y().minimum() && position.getBlockY() <= extent.y().maximum()); + assertTrue(context + " z " + position.getBlockZ() + " outside " + extent.z(), + position.getBlockZ() >= extent.z().minimum() && position.getBlockZ() <= extent.z().maximum()); + }); + int centerX = object.getCenter().getBlockX(); + int centerZ = object.getCenter().getBlockZ(); + assertTrue(context + " frame x", -centerX >= extent.x().minimum() && object.getW() - 1 - centerX <= extent.x().maximum()); + assertTrue(context + " frame z", -centerZ >= extent.z().minimum() && object.getD() - 1 - centerZ <= extent.z().maximum()); + } + + private static IrisObjectRotation rotation(IrisAxisRotationClamp x, IrisAxisRotationClamp y, IrisAxisRotationClamp z) { + IrisObjectRotation rotation = new IrisObjectRotation(); + rotation.setEnabled(true); + rotation.setXAxis(x); + rotation.setYAxis(y); + rotation.setZAxis(z); + return rotation; + } + + private NativeBlockState state(String key) { + return states.computeIfAbsent(key, ignored -> { + String material = key.toLowerCase(Locale.ROOT); + boolean air = material.endsWith("air"); + NativeBlockState state = mock(NativeBlockState.class, withSettings().stubOnly()); + when(state.key()).thenReturn(key); + when(state.materialKey()).thenReturn(material); + when(state.isAir()).thenReturn(air); + when(state.isSolid()).thenReturn(!air); + when(state.isOccluding()).thenReturn(!air); + return state; + }); + } + + private static final class RecordingPlacer implements IObjectPlacer { + private final Engine engine; + private int writeReach; + private int readReach; + + private RecordingPlacer(Engine engine) { + this.engine = engine; + } + + private void write(int x, int z) { + writeReach = Math.max(writeReach, Math.max(Math.abs(x - ORIGIN_X), Math.abs(z - ORIGIN_Z))); + read(x, z); + } + + private void read(int x, int z) { + readReach = Math.max(readReach, Math.max(Math.abs(x - ORIGIN_X), Math.abs(z - ORIGIN_Z))); + } + + @Override + public int getHighest(int x, int z, IrisData data) { + read(x, z); + return GROUND_Y; + } + + @Override + public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { + read(x, z); + return GROUND_Y; + } + + @Override + public void set(int x, int y, int z, NativeBlockState d) { + write(x, z); + } + + @Override + public NativeBlockState get(int x, int y, int z) { + read(x, z); + return null; + } + + @Override + public boolean isPreventingDecay() { + return false; + } + + @Override + public boolean isCarved(int x, int y, int z) { + read(x, z); + return false; + } + + @Override + public boolean isSolid(int x, int y, int z) { + read(x, z); + return y < GROUND_Y; + } + + @Override + public boolean isUnderwater(int x, int z) { + read(x, z); + return false; + } + + @Override + public int getFluidHeight() { + return GROUND_Y - 1; + } + + @Override + public boolean isDebugSmartBore() { + return false; + } + + @Override + public void setTile(int xx, int yy, int zz, TileData tile) { + write(xx, zz); + } + + @Override + public void setData(int xx, int yy, int zz, T data) { + write(xx, zz); + } + + @Override + public T getData(int xx, int yy, int zz, Class t) { + read(xx, zz); + return null; + } + + @Override + public Engine getEngine() { + return engine; + } + } +}