diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java b/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java index f0739750e..b00f21993 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java @@ -87,6 +87,10 @@ import java.util.stream.Collectors; public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, Hotloadable { IrisComplex getComplex(); + default art.arcane.iris.structure.object.PlacementLifecycles getPlacementLifecycles() { + return art.arcane.iris.structure.object.PlacementLifecycles.defaults(); + } + default BiomeEnvironment getBiomeEnvironment(int x, int y, int z) { return new BiomeEnvironment(0L, getBiome(x, y, z), getRegion(x, y, z), getDimension(), getData()); } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java b/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java index 05d704260..d999c4287 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java @@ -96,7 +96,8 @@ final class EngineHotloader { assembly.complex, assembly.upperContext, assembly.dimensionStackContext, - biomeMaxes); + biomeMaxes, + assembly.placementLifecycles); next = previous.withGeneration(nextGeneration); } finally { engine.threadState.setAssembly(null); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EngineRuntimeBuilder.java b/core/src/main/java/art/arcane/iris/generation/runtime/EngineRuntimeBuilder.java index 79fe292cd..8d42412fc 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/EngineRuntimeBuilder.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/EngineRuntimeBuilder.java @@ -18,6 +18,8 @@ package art.arcane.iris.generation.runtime; +import art.arcane.iris.structure.object.PlacementLifecycles; + import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.ResourceLoader; import art.arcane.iris.generation.runtime.GenerationRuntime.BiomeMaxes; @@ -420,6 +422,7 @@ final class EngineRuntimeBuilder { final GenerationKernelRegistry.RuntimeKernel runtimeKernel; final SeedManager seedManager; final TransitionGenerationPlan transitionPlan; + final PlacementLifecycles placementLifecycles; static int nextRuntimeId() { return RUNTIME_IDS.incrementAndGet(); @@ -468,6 +471,7 @@ final class EngineRuntimeBuilder { this.seedManager = this.runtimeKernel.createSeedManager(target.getWorld().getRawWorldSeed()); this.kernelVersion = this.runtimeKernel.version(); this.transitionPlan = transitionPlan; + this.placementLifecycles = PlacementLifecycles.resolve(); } GenerationRuntime freezeGeneration(BiomeMaxes biomeMaxes) { @@ -490,7 +494,8 @@ final class EngineRuntimeBuilder { mantle, mantleStorageDirectory, hash32, - biomeMaxes); + biomeMaxes, + placementLifecycles); } EngineRuntime freezeRuntime(BiomeMaxes biomeMaxes) { diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationRuntime.java b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationRuntime.java index 61463574a..4c841a963 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationRuntime.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationRuntime.java @@ -18,6 +18,8 @@ package art.arcane.iris.generation.runtime; +import art.arcane.iris.structure.object.PlacementLifecycles; + import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.world.history.TransitionGenerationPlan; import art.arcane.iris.world.history.GenerationKernelRegistry; @@ -44,7 +46,8 @@ record GenerationRuntime( EngineMantle mantle, Path mantleStorageDirectory, CompletableFuture hash32, - BiomeMaxes biomeMaxes + BiomeMaxes biomeMaxes, + PlacementLifecycles placementLifecycles ) { GenerationRuntime { Objects.requireNonNull(target, "generation target"); @@ -61,6 +64,7 @@ record GenerationRuntime( Objects.requireNonNull(mantle, "generation mantle"); mantleStorageDirectory = IrisEngineMantle.normalizeStorageDirectory(mantleStorageDirectory); Objects.requireNonNull(hash32, "generation pack hash"); + Objects.requireNonNull(placementLifecycles, "placement lifecycles"); Objects.requireNonNull(biomeMaxes, "generation biome maximums"); if (target.getData() != data || target.getDimension() != dimension) { throw new IllegalArgumentException("Generation target, data, and dimension must describe one runtime."); @@ -72,7 +76,8 @@ record GenerationRuntime( IrisComplex nextComplex, UpperDimensionContext nextUpperContext, DimensionStackContext nextDimensionStackContext, - BiomeMaxes nextBiomeMaxes + BiomeMaxes nextBiomeMaxes, + PlacementLifecycles nextPlacementLifecycles ) { return new GenerationRuntime( nextCacheId, @@ -90,7 +95,8 @@ record GenerationRuntime( mantle, mantleStorageDirectory, hash32, - nextBiomeMaxes); + nextBiomeMaxes, + nextPlacementLifecycles); } record BiomeMaxes(double objectDensity, double layerDensity, double decoratorDensity) { diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java index 7145da0b2..1a38b8ee0 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java @@ -1403,6 +1403,20 @@ public class IrisEngine implements Engine { return current == null ? null : current.complex(); } + @Override + public art.arcane.iris.structure.object.PlacementLifecycles getPlacementLifecycles() { + EngineThreadState.Frame frame = threadState.current(); + RuntimeAssembly assembly = frame == null ? null : frame.assembly; + if (assembly != null) { + return assembly.placementLifecycles; + } + GenerationRuntime current = selectedGenerationRuntime(frame); + if (current == null) { + throw new IllegalStateException("Placement requires an active Iris runtime."); + } + return current.placementLifecycles(); + } + @Override public EngineTarget getTarget() { return targetIn(threadState.current()); diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java index 36d05ab82..5ba1971d1 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java @@ -18,1435 +18,28 @@ package art.arcane.iris.structure.object; -import art.arcane.iris.generation.block.BlockDataMergeSupport; -import art.arcane.iris.generation.block.TileData; import art.arcane.iris.generation.cave.CarveResult; -import art.arcane.iris.generation.cave.CarvingMode; -import art.arcane.iris.generation.decoration.IrisProceduralBlocks; -import art.arcane.iris.generation.decoration.tree.TreeGrounding; -import art.arcane.iris.generation.decoration.IrisStiltSettings; -import art.arcane.iris.structure.object.IrisSurfaceSupport; -import art.arcane.iris.generation.decoration.IrisVacuumSettings; -import art.arcane.iris.world.entity.IrisMarker; import art.arcane.iris.pack.loading.IrisData; -import art.arcane.iris.generation.runtime.IrisComplex; -import art.arcane.iris.generation.runtime.Engine; -import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; -import art.arcane.iris.structure.placement.PlacedObject; -import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; -import art.arcane.iris.generation.block.B; -import art.arcane.iris.generation.block.VectorMap; -import art.arcane.iris.generation.geometry.IrisBlockVector; -import art.arcane.volmlib.util.noise.CNG; -import art.arcane.volmlib.util.noise.SimplexNoise; -import art.arcane.volmlib.util.stream.ProceduralStream; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.BlockPosition; -import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.math.RNG; -import art.arcane.volmlib.util.matter.MatterMarker; -import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap; -import java.util.Comparator; -import java.util.IdentityHashMap; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiConsumer; -import java.util.stream.StreamSupport; -/** - * One-shot placement of an {@link IrisObject} into the world. Instances are cheap and single use: they exist so - * the placement pass can hoist its per-placement invariants without leaking that state onto the loader-cached - * (and thread-shared) object itself. - */ +/** Entry point for executing an engine's resolved placement plan. */ final class IrisObjectPlacementRunner { - private static final long IMPLAUSIBLE_BEDROCK_WARN_THROTTLE_MS = 5000L; - private static final long VACUUM_WAVE_SEED = 7392113L; - private static final ConcurrentHashMap IMPLAUSIBLE_BEDROCK_WARNS = new ConcurrentHashMap<>(); - private final IrisObject self; private final IrisObjectPlacementAlignment alignment; IrisObjectPlacementRunner(IrisObject self, IrisObjectPlacementAlignment alignment) { this.self = self; - this.alignment = Objects.requireNonNull(alignment); + this.alignment = alignment; } - int place(int x, int yv, int z, IObjectPlacer oplacer, IrisObjectPlacement config, RNG rng, BiConsumer listener, CarveResult c, IrisData rdata) { - Objects.requireNonNull(oplacer, "Object placer is required."); - Objects.requireNonNull(config, "Object placement config is required."); - Objects.requireNonNull(rng, "Object placement RNG is required."); - Objects.requireNonNull(rdata, "Object placement data is required."); - IObjectPlacer placer = config.getHeightmap() != null ? new HeightmapObjectPlacer(rng, x, yv, z, config, oplacer) : oplacer; - - boolean evaluateSlopeCondition = !config.isForcePlace() && !config.getSlopeCondition().isDefault(); - if (evaluateSlopeCondition || config.isRotateTowardsSlope()) { - Engine placementEngine = requireSlopeEngine(placer); - if (evaluateSlopeCondition && - !config.getSlopeCondition().isValid(placementEngine.getComplex().getSlopeStream().get(x, z))) { - return -1; - } - - if (config.isRotateTowardsSlope()) { - int slopeRotationY = 0; - ProceduralStream heightStream = placementEngine.getComplex().getHeightStream(); - // Whichever side of the rectangle that bounds the object is lowest is the 'direction' of the slope (simply said). - double hNorth = heightStream.get(x, z + ((float) self.d) / 2); - double hEast = heightStream.get(x + ((float) self.w) / 2, z); - double hSouth = heightStream.get(x, z - ((float) self.d) / 2); - double hWest = heightStream.get(x - ((float) self.w) / 2, z); - double min = Math.min(Math.min(hNorth, hEast), Math.min(hSouth, hWest)); - if (min == hNorth) { - slopeRotationY = 0; - } else if (min == hEast) { - slopeRotationY = 90; - } else if (min == hSouth) { - slopeRotationY = 180; - } else if (min == hWest) { - slopeRotationY = 270; - } - - double newRotation = config.getRotation().getYAxis().getMin() + slopeRotationY; - IrisObjectRotation originalRotation = config.getRotation(); - IrisObjectRotation slopeRotation = new IrisObjectRotation(); - slopeRotation.setXAxis(originalRotation.getXAxis()); - slopeRotation.setZAxis(originalRotation.getZAxis()); - if (newRotation == 0) { - slopeRotation.setYAxis(new IrisAxisRotationClamp(false, false, 0, 0, 90)); - slopeRotation.setEnabled(originalRotation.canRotateX() || originalRotation.canRotateZ()); - } else { - slopeRotation.setYAxis(new IrisAxisRotationClamp(true, false, newRotation, newRotation, 90)); - slopeRotation.setEnabled(true); - } - config = config.toPlacement(config.getPlace().toArray(new String[0])); - config.setRotation(slopeRotation); - } - } - - boolean warped = !config.getWarp().isFlat(); - // Placement-invariant hoists off the per-voxel loop: the warp CNG resolution went - // through a keyed cache probe per block, and the two boolean gates below cut per-block - // property scans that can never match for this placement. - CNG surfaceWarp = warped ? config.getSurfaceWarp(rng, self.getLoader()) : null; - double warpHalf = warped ? config.getWarp().getMultiplier() / 2D : 0D; - boolean preventingDecay = placer.isPreventingDecay(); - boolean waterlogCandidate = config.isWaterloggable() || config.isUnderwater(); - boolean rawStructurePiece = config.getMode() == ObjectPlaceMode.STRUCTURE_PIECE; - boolean paint = yv < 0 && config.getMode() == ObjectPlaceMode.PAINT; - boolean organicFloor = config.getMode() == ObjectPlaceMode.ORGANIC_STILT; - boolean ceilingHang = config.getMode() == ObjectPlaceMode.CEILING_HANG; - boolean organic = organicFloor || ceilingHang; - boolean vacuuming = IrisObjectVacuum.isVacuumMode(config.getMode()); - boolean stilting = (config.getMode().equals(ObjectPlaceMode.STILT) || config.getMode().equals(ObjectPlaceMode.FAST_STILT) || - config.getMode() == ObjectPlaceMode.MIN_STILT || config.getMode() == ObjectPlaceMode.FAST_MIN_STILT || - config.getMode() == ObjectPlaceMode.CENTER_STILT || config.getMode() == ObjectPlaceMode.ERODE_STILT || organic); - boolean eroding = config.getMode() == ObjectPlaceMode.ERODE_STILT; - IrisStiltSettings stiltSettings = config.getStiltSettings(); - Set excludedStiltMaterials = stilting && stiltSettings != null - ? stiltSettings.excludedMaterials() : Set.of(); - KMap heightmap = config.getSnow() > 0 ? new KMap<>() : null; - int spinx = rng.imax() / 1000; - int spiny = rng.imax() / 1000; - int spinz = rng.imax() / 1000; - int rty = config.getRotation().rotate(new IrisBlockVector(0, self.getCenter().getBlockY(), 0), spinx, spiny, spinz).getBlockY(); - int ty = config.getTranslate().translate(new IrisBlockVector(0, self.getCenter().getBlockY(), 0), config.getRotation(), spinx, spiny, spinz).getBlockY(); - // Per-placement invariants. The rotation kernel, the rotated translate offset and the edit-list emptiness - // are pure functions of (config, spin), all of which are fixed from here down. Recomputing them per block - // only cost allocations and transcendentals. - SpinKernel spin = new SpinKernel(config.getRotation(), spinx, spiny, spinz); - IrisObjectTranslate translate = config.getTranslate(); - boolean translating = translate.canTranslate(); - IrisBlockVector translateOffset = translating - ? config.getRotation().rotate(new IrisBlockVector(translate.getX(), translate.getY(), translate.getZ()), spinx, spiny, spinz) - : null; - boolean hasEdits = !config.getEdit().isEmpty(); - int y = -1; - int xx, zz; - int yrand = config.getTranslate().getYRandom(); - yrand = yrand > 0 ? rng.i(0, yrand) : yrand < 0 ? rng.i(yrand, 0) : yrand; - int warpMargin = warped ? (int) Math.ceil(Math.abs(config.getWarp().getMultiplier()) / 2D) : 0; - TransformedBounds placementBounds = transformedBounds(spin, translating, translateOffset, ceilingHang, warpMargin); - boolean bail = false; - - if (alignment == IrisObjectPlacementAlignment.FLOOR) { - if (yv < 0 || ceilingHang || rawStructurePiece) { - throw new IllegalArgumentException("Floor alignment requires an explicit floor Y and a floor placement mode."); - } - int minimumY = minimumOccupiedY(spin); - if (minimumY == Integer.MAX_VALUE) { - return -1; - } - y = yv - minimumY; - if (!config.isForcePlace() && shouldBailForCarvingAnchor(placer, config, x, yv, z)) { - bail = true; - } - } else if (config.isFromBottom()) { - // todo Convert this to a dedicated mode. - y = (self.getH() + 1) + rty; - if (!config.isForcePlace()) { - if (shouldBailForCarvingAnchor(placer, config, x, y, z)) { - bail = true; - } - } - } else if (yv < 0) { - if (config.getMode().equals(ObjectPlaceMode.CENTER_HEIGHT) || config.getMode() == ObjectPlaceMode.CENTER_STILT - || organic || vacuuming) { - y = (c != null ? c.getSurface() : placer.getHighest(x, z, self.getLoader(), config.isUnderwater())) + rty; - if (!config.isForcePlace()) { - if (shouldBailForCarvingAnchor(placer, config, x, y, z)) { - bail = true; - } - } - } else if (config.getMode().equals(ObjectPlaceMode.MAX_HEIGHT) || config.getMode().equals(ObjectPlaceMode.STILT)) { - int minX = x + placementBounds.minX(); - int maxX = x + placementBounds.maxX(); - int minZ = z + placementBounds.minZ(); - int maxZ = z + placementBounds.maxZ(); - for (int i = minX; i <= maxX; i++) { - for (int ii = minZ; ii <= maxZ; ii++) { - int h = placer.getHighest(i, ii, self.getLoader(), config.isUnderwater()) + rty; - if (!config.isForcePlace()) { - if (shouldBailForCarvingAnchor(placer, config, i, h, ii)) { - bail = true; - break; - } - } - if (h > y) - y = h; - } - } - } else if (config.getMode().equals(ObjectPlaceMode.FAST_MAX_HEIGHT) || config.getMode().equals(ObjectPlaceMode.FAST_STILT)) { - int minX = x + placementBounds.minX(); - int maxX = x + placementBounds.maxX(); - int minZ = z + placementBounds.minZ(); - int maxZ = z + placementBounds.maxZ(); - int xRadius = Math.max(0, (maxX - minX) / 2); - int zRadius = Math.max(0, (maxZ - minZ) / 2); - - for (int i = minX; i <= maxX; i += Math.abs(xRadius) + 1) { - for (int ii = minZ; ii <= maxZ; ii += Math.abs(zRadius) + 1) { - int h = placer.getHighest(i, ii, self.getLoader(), config.isUnderwater()) + rty; - if (!config.isForcePlace()) { - if (shouldBailForCarvingAnchor(placer, config, i, h, ii)) { - bail = true; - break; - } - } - if (h > y) - y = h; - } - } - } else if (config.getMode().equals(ObjectPlaceMode.MIN_HEIGHT) || config.getMode() == ObjectPlaceMode.MIN_STILT) { - y = requireDataEngine(rdata, "minimum-height mode").getHeight() + 1; - int minX = x + placementBounds.minX(); - int maxX = x + placementBounds.maxX(); - int minZ = z + placementBounds.minZ(); - int maxZ = z + placementBounds.maxZ(); - for (int i = minX; i <= maxX; i++) { - for (int ii = minZ; ii <= maxZ; ii++) { - int h = placer.getHighest(i, ii, self.getLoader(), config.isUnderwater()) + rty; - if (!config.isForcePlace()) { - if (shouldBailForCarvingAnchor(placer, config, i, h, ii)) { - bail = true; - break; - } - } - if (h < y) { - y = h; - } - } - } - } else if (config.getMode().equals(ObjectPlaceMode.FAST_MIN_HEIGHT) || config.getMode() == ObjectPlaceMode.FAST_MIN_STILT) { - y = requireDataEngine(rdata, "fast minimum-height mode").getHeight() + 1; - int minX = x + placementBounds.minX(); - int maxX = x + placementBounds.maxX(); - int minZ = z + placementBounds.minZ(); - int maxZ = z + placementBounds.maxZ(); - int xRadius = Math.max(0, (maxX - minX) / 2); - int zRadius = Math.max(0, (maxZ - minZ) / 2); - - for (int i = minX; i <= maxX; i += Math.abs(xRadius) + 1) { - for (int ii = minZ; ii <= maxZ; ii += Math.abs(zRadius) + 1) { - int h = placer.getHighest(i, ii, self.getLoader(), config.isUnderwater()) + rty; - if (!config.isForcePlace()) { - if (shouldBailForCarvingAnchor(placer, config, i, h, ii)) { - bail = true; - break; - } - } - if (h < y) { - y = h; - } - } - } - } else if (config.getMode().equals(ObjectPlaceMode.PAINT)) { - int anchorX = x + (translating ? (int) Math.round(translateOffset.getX()) : 0); - int anchorZ = z + (translating ? (int) Math.round(translateOffset.getZ()) : 0); - y = (c != null ? c.getSurface() - : placer.getHighest(anchorX, anchorZ, self.getLoader(), config.isUnderwater())) + rty; - if (!config.isForcePlace()) { - if (shouldBailForCarvingAnchor(placer, config, x, y, z)) { - bail = true; - } - } - } else if (config.getMode().equals(ObjectPlaceMode.FLOATING)) { - y = rty; - } - } else { - y = yv; - if (!config.isForcePlace() && !rawStructurePiece) { - if (shouldBailForCarvingAnchor(placer, config, x, y, z)) { - bail = true; - } - } - } - - if (yv >= 0 && config.isBottom() && !rawStructurePiece && alignment != IrisObjectPlacementAlignment.FLOOR) { - y += Math.floorDiv(self.h, 2); - CarvingMode carvingMode = config.getCarvingSupport(); - if (!config.isForcePlace() && !carvingMode.equals(CarvingMode.CARVING_ONLY)) { - if (shouldBailForCarvingAnchor(placer, config, x, y, z)) { - bail = true; - } - } - } - - if (yv < 0 - && !config.isForcePlace() - && !config.isFromBottom() - && config.getMode() != ObjectPlaceMode.FLOATING - && !rawStructurePiece - && config.getCarvingSupport().supportsSurface() - && placer.getEngine() != null - && placer.getEngine().getDimension().isBedrock() - && y <= 1) { - warnImplausibleBedrockPlacement(placer, config, x, y, z); - return -1; - } - - if (bail && !config.isForcePlace()) { - return -1; - } - - TreeGrounding.Plan grounding = null; - if (self.getTreeGrounding() != null && yv < 0 && config.getCarvingSupport().supportsSurface() - && !config.isForcePlace() && !config.isFromBottom() && !paint && !ceilingHang - && config.getMode() != ObjectPlaceMode.FLOATING && !rawStructurePiece) { - grounding = self.getTreeGrounding().plan(placer, new TreeGrounding.Placement(rdata, config, - new BlockPosition(x, y + yrand, z), new BlockPosition(spinx, spiny, spinz))); - if (!grounding.supported()) { - return -1; - } - stilting = false; - } - - IrisPaintSurfaceProjection paintProjection = null; - KList paintSupport = null; - if (paint) { - int buffer = config.isRequireSurfaceSupport() ? Math.max(0, config.getSurfaceSupportBuffer()) : 0; - int anchorX = x + (translating ? (int) Math.round(translateOffset.getX()) : 0); - int anchorZ = z + (translating ? (int) Math.round(translateOffset.getZ()) : 0); - paintProjection = IrisPaintSurfaceProjection.create(placer, new IrisPaintSurfaceProjection.Request( - self.getLoader(), x + placementBounds.minX() - buffer, x + placementBounds.maxX() + buffer, - z + placementBounds.minZ() - buffer, z + placementBounds.maxZ() + buffer, - anchorX, y - rty, anchorZ, config.isUnderwater(), config.getHeightmap() != null)); - if (paintProjection == null) { - return -1; - } - paintSupport = transformedPaintSupport(spin, translateOffset, surfaceWarp, warpHalf, x, y + yrand, z); - } - - if (yv < 0 - && config.getCarvingSupport().supportsSurface() - && config.getMode() != ObjectPlaceMode.FLOATING - && !rawStructurePiece - && (paint ? IrisSurfaceSupport.intersectsHydrology(oplacer, x, z, null, null, - 0, 0, 0, paintSupport) - : IrisSurfaceSupport.intersectsHydrology(oplacer, x, z, config.getTranslate(), - config.getRotation(), spinx, spiny, spinz, self.getSurfaceSupportOffsets()))) { - return -1; - } - - // Surface-anchored placements may never roof or bridge a carved hole. Explicit-Y anchors are only - // guarded for SURFACE_ONLY: ANYWHERE covers the inverted upper dimension and CARVING_ONLY covers - // cave anchors, and neither reads the terrain surface this stencil samples. - boolean surfaceAnchored = yv < 0 - ? config.getCarvingSupport().supportsSurface() - : config.getCarvingSupport() == CarvingMode.SURFACE_ONLY; - if (surfaceAnchored - && !config.isForcePlace() - && !config.isFromBottom() - && config.getMode() != ObjectPlaceMode.FLOATING - && !rawStructurePiece - && !config.isUnderwater() - && !config.isOnwater() - && config.isRequireSurfaceSupport() - && (paint ? IrisSurfaceSupport.isUnsupported(oplacer, self.getLoader(), x, z, null, null, - 0, 0, 0, paintSupport, config.getSurfaceSupportBuffer(), config.getSurfaceSupportDepth(), - paintProjection) - : IrisSurfaceSupport.isUnsupported(oplacer, self.getLoader(), x, z, config.getTranslate(), - config.getRotation(), spinx, spiny, spinz, self.getSurfaceSupportOffsets(), - config.getSurfaceSupportBuffer(), config.getSurfaceSupportDepth()))) { - return -1; - } - - if (yv < 0 && !config.getMode().equals(ObjectPlaceMode.FLOATING) && !rawStructurePiece) { - if (!config.isForcePlace() && !config.isUnderwater() && !config.isOnwater() && placer.isUnderwater(x, z)) { - return -1; - } - } - - if (!config.isForcePlace() && !rawStructurePiece && c != null && Math.max(0, self.h + yrand + ty) + 1 >= c.getHeight()) { - return -1; - } - - if (!config.isForcePlace() && !rawStructurePiece && config.isUnderwater() - && y + rty + ty >= placer.getFluidHeight(x, z)) { - return -1; - } - - if (!config.isForcePlace() && !rawStructurePiece && !config.getClamp().canPlace(y + rty + ty, y - rty + ty)) { - return -1; - } - - if (!rawStructurePiece && nativeStructureVetoes(placer, config, spin, placementBounds, translating, translateOffset, ceilingHang, - paintProjection, surfaceWarp, warpHalf, yrand, warpMargin, x, y + yrand, z)) { - return -1; - } - - WorldBounds worldBounds = null; - if (config.isBore() || (!config.isForcePlace() && !rawStructurePiece - && (!config.getAllowedCollisions().isEmpty() || !config.getForbiddenCollisions().isEmpty()))) { - worldBounds = resolveWorldBounds(placementBounds, paintProjection, yrand, x, y + yrand, z); - } - - if (!config.isForcePlace() && !rawStructurePiece && (!config.getAllowedCollisions().isEmpty() || !config.getForbiddenCollisions().isEmpty())) { - Engine engine = requireDataEngine(rdata, "collision settings"); - for (int i = worldBounds.minX(); i <= worldBounds.maxX(); i++) { - for (int k = worldBounds.minZ(); k <= worldBounds.maxZ(); k++) { - int surface = paint ? paintProjection.surfaceY(i, k) : 0; - if (surface == IrisPaintSurfaceProjection.MISSING) { - continue; - } - int minimumY = paint ? surface + placementBounds.minY() + Math.floorDiv(self.h, 2) + yrand : worldBounds.minY(); - int maximumY = paint ? surface + placementBounds.maxY() + Math.floorDiv(self.h, 2) + yrand : worldBounds.maxY(); - for (int j = minimumY; j <= maximumY; j++) { - String placementMarker = placer.getData(i, j, k, String.class); - PlacedObject p = engine.resolveObjectPlacementMarker(i, k, placementMarker); - if (p == null) continue; - IrisObject o = p.getObject(); - if (o == null) continue; - String key = o.getLoadKey(); - if (key != null) { - if (config.getForbiddenCollisions().contains(key) && !config.getAllowedCollisions().contains(key)) { - return -1; - } - } - } - } - } - } - - y += yrand; - - if (config.isBore()) { - for (int i = worldBounds.minX(); i <= worldBounds.maxX(); i++) { - for (int k = worldBounds.minZ(); k <= worldBounds.maxZ(); k++) { - int surface = paint ? paintProjection.surfaceY(i, k) : 0; - if (surface == IrisPaintSurfaceProjection.MISSING) { - continue; - } - int minimumY = paint ? surface + placementBounds.minY() + Math.floorDiv(self.h, 2) + yrand : worldBounds.minY(); - int maximumY = paint ? surface + placementBounds.maxY() + Math.floorDiv(self.h, 2) + yrand : worldBounds.maxY(); - for (int j = minimumY - config.getBoreExtendMinY(); j <= maximumY + config.getBoreExtendMaxY(); j++) { - placer.set(i, j, k, IrisObject.States.air()); - } - } - } - } - - int lowest = Integer.MAX_VALUE; - int topLayer = Integer.MIN_VALUE; - int vacuumLowest = Integer.MAX_VALUE; - int vacuumHighest = Integer.MIN_VALUE; - self.readLock.lock(); - try { - ObjectPlacementBake bake = ObjectPlacementBake.of(self, spin, ceilingHang, translating ? translateOffset : null); - PlacementStates palette = new PlacementStates(bake.palette(), config.getRotation(), spinx, spiny, spinz, - preventingDecay, placer.isDebugSmartBore(), excludedStiltMaterials); - double[] exact = warped || hasEdits || eroding ? bake.exact(self, spin) : null; - String[] markers = config.getMarkers().isNotEmpty() && placer.getEngine() != null - ? resolveMarkers(config, rng, x, yv, z, rdata, bake) : null; - int[] tileBlocks = bake.tileBlocks(); - TileData[] tileValues = bake.tileValues(); - int tileCursor = 0; - int count = bake.count(); - - for (int index = 0; index < count; index++) { - int entry = bake.paletteIndex(index); - TileData tile = null; - if (tileBlocks != null && tileCursor < tileBlocks.length && tileBlocks[tileCursor] == index) { - tile = tileValues[tileCursor++]; - } - - if (stilting && palette.stiltLayer(entry)) { - int layer = bake.layerY(index); - if (layer < lowest) { - lowest = layer; - } - if (layer > topLayer) { - topLayer = layer; - } - } - - NativeBlockState data = palette.decayed(entry); - boolean edited = false; - if (hasEdits) { - double exactX = exact[index * 3]; - double exactY = exact[index * 3 + 1]; - double exactZ = exact[index * 3 + 2]; - for (IrisObjectReplace j : config.getEdit()) { - if (rng.chance(j.getChance())) { - for (NativeBlockState k : j.getFind(rdata)) { - if (j.isExact() ? k.matches(data) : IrisObjectShaping.materialKey(k).equals(IrisObjectShaping.materialKey(data))) { - NativeBlockState newData = j.getReplace(rng, exactX + x, exactY + y, exactZ + z, rdata); - - boolean sameMaterial = IrisObjectShaping.materialKey(newData).equals(IrisObjectShaping.materialKey(data)); - if (sameMaterial && !(newData.isCustom() || data.isCustom())) - data = BlockDataMergeSupport.merge(data, newData); - else - data = newData; - edited = true; - - Optional t = j.getReplace().getTile(rng, x, y, z, rdata); - if (t.isPresent()) { - tile = t.get(); - } else if (!sameMaterial) { - tile = null; - } - } - } - } - } - } - - StateFacts facts = edited ? palette.factsOf(data) : palette.facts(entry); - NativeBlockState rotated = facts.rotated(); - if (rotated == null) { - continue; - } - xx = x + bake.offsetX(index); - int yy = y + bake.offsetY(index); - zz = z + bake.offsetZ(index); - - if (warped) { - double exactX = exact[index * 3]; - double exactY = exact[index * 3 + 1]; - double exactZ = exact[index * 3 + 2]; - xx += surfaceWarp.fitDouble(-warpHalf, warpHalf, exactX + x, exactY + y, exactZ + z); - zz += surfaceWarp.fitDouble(-warpHalf, warpHalf, exactZ + z, exactY + y, exactX + x); - } - - if (paint) { - int surface = paintProjection.surfaceY(xx, zz); - if (surface == IrisPaintSurfaceProjection.MISSING) { - continue; - } - if (!B.isVineBlock(rotated)) { - yy = bake.offsetY(index) + Math.floorDiv(self.h, 2) + surface + yrand; - } - } - - if (config.isMeld() && !rawStructurePiece && !placer.isSolid(xx, yy, zz)) { - continue; - } - - boolean canopy = bake.canopy(index); - if (canopy && !placer.beginTreeCanopy(new BlockPosition(xx, yy, zz))) { - continue; - } - - try { - data = rotated; - if (facts.waterloggable()) { - data = waterlogCandidate || facts.authoredWaterlogged() - ? IrisProceduralBlocks.normalizeWaterlogging(data, placer.get(xx, yy, zz), waterlogCandidate) - : facts.dry(); - } - - if (!rawStructurePiece && B.isVineBlock(data)) { - data = attachVineFaces(placer, data, xx, yy, zz); - } - - // Short-circuit order matters for cost only: the mantle read is paid solely - // for vine blocks. Both operands are pure, so the value is unchanged. - boolean wouldReplace = !rawStructurePiece && B.isVineBlock(data) && B.isSolid(placer.get(xx, yy, zz)); - boolean place = shouldPlaceObjectBlock(rawStructurePiece, facts.air(), wouldReplace); - - if (data.isCustom() || place) { - placer.set(xx, yy, zz, data); - if (heightmap != null) { - Position2 pos = new Position2(xx, zz); - Integer currentHeight = heightmap.get(pos); - if (currentHeight == null || currentHeight < yy) { - heightmap.put(pos, yy); - } - } - if (tile != null) { - placer.setTile(xx, yy, zz, tile); - } - if (markers != null && markers[index] != null) { - placer.setData(xx, yy, zz, new MatterMarker(markers[index])); - } - if (listener != null) { - listener.accept(new BlockPosition(xx, yy, zz), data); - } - if (vacuuming && yy < vacuumLowest) { - vacuumLowest = yy; - } - if (vacuuming && yy > vacuumHighest) { - vacuumHighest = yy; - } - } - } finally { - if (canopy) { - placer.endTreeCanopy(); - } - } - } - - if (grounding != null) { - for (TreeGrounding.Extension extension : grounding.extensions()) { - for (int rootY = extension.topY(); rootY >= extension.bottomY(); rootY--) { - NativeBlockState rootState = normalizePlacedWaterlogging(extension.state(), placer, - extension.x(), rootY, extension.z(), waterlogCandidate); - placer.set(extension.x(), rootY, extension.z(), rootState); - if (listener != null) { - listener.accept(new BlockPosition(extension.x(), rootY, extension.z()), rootState); - } - } - } - } - - if (stilting) { - IrisStiltSettings settings = stiltSettings; - - double erodeCentroidX = 0; - double erodeCentroidZ = 0; - double erodeMaxDist = 1; - if (eroding) { - int centroidCount = 0; - for (int index = 0; index < count; index++) { - if (bake.layerY(index) == lowest && palette.shouldStilt(bake.paletteIndex(index))) { - erodeCentroidX += exact[index * 3]; - erodeCentroidZ += exact[index * 3 + 2]; - centroidCount++; - } - } - if (centroidCount > 0) { - erodeCentroidX /= centroidCount; - erodeCentroidZ /= centroidCount; - } - for (int index = 0; index < count; index++) { - if (bake.layerY(index) == lowest && palette.shouldStilt(bake.paletteIndex(index))) { - double dx = exact[index * 3] - erodeCentroidX; - double dz = exact[index * 3 + 2] - erodeCentroidZ; - double dist = Math.sqrt(dx * dx + dz * dz); - if (dist > erodeMaxDist) { - erodeMaxDist = dist; - } - } - } - } - - int targetLayer = ceilingHang ? topLayer : lowest; - for (int index = 0; index < count; index++) { - int entry = bake.paletteIndex(index); - if (!palette.shouldStilt(entry)) { - continue; - } - - NativeBlockState d; - if (settings != null && settings.getPalette() != null) { - d = config.getStiltSettings().getPalette().get(rng, x, y, z, rdata); - } else { - d = palette.stiltDefault(entry); - } - - if (bake.layerY(index) != targetLayer) - continue; - d = palette.rotate(d); - - if (hasEdits) { - double exactX = exact[index * 3]; - double exactY = exact[index * 3 + 1]; - double exactZ = exact[index * 3 + 2]; - for (IrisObjectReplace j : config.getEdit()) { - if (rng.chance(j.getChance())) { - for (NativeBlockState k : j.getFind(rdata)) { - if (d == null) { - continue; - } - if (j.isExact() ? k.matches(d) : IrisObjectShaping.materialKey(k).equals(IrisObjectShaping.materialKey(d))) { - NativeBlockState newData = j.getReplace(rng, exactX + x, exactY + y, exactZ + z, rdata); - - if (IrisObjectShaping.materialKey(newData).equals(IrisObjectShaping.materialKey(d))) { - d = BlockDataMergeSupport.merge(d, newData); - } else { - d = newData; - } - } - } - } - } - } - - if (d == null || !palette.shouldStiltPlaced(d)) - continue; - - xx = x + bake.offsetX(index); - zz = z + bake.offsetZ(index); - - if (warped) { - double exactX = exact[index * 3]; - double exactY = exact[index * 3 + 1]; - double exactZ = exact[index * 3 + 2]; - xx += surfaceWarp.fitDouble(-warpHalf, warpHalf, exactX + x, exactY + y, exactZ + z); - zz += surfaceWarp.fitDouble(-warpHalf, warpHalf, exactZ + z, exactY + y, exactX + x); - } - - if (organic) { - int startY = targetLayer + y; - int maxScan = settings != null ? Math.max(1, settings.getOrganicMaxScan()) : 48; - int jitterMax = settings != null ? Math.max(0, settings.getOrganicJitter()) : 3; - double scratch = settings != null ? Math.max(0, Math.min(1, settings.getOrganicScratch())) : 0.55; - long colHash = ((long) xx * 341873128712L) ^ ((long) zz * 132897987541L); - int jitter = jitterMax > 0 ? (int) (Math.abs(colHash) % (jitterMax + 1)) : 0; - - if (ceilingHang) { - int scan = 0; - int solidY = startY + 1; - while (scan < maxScan && !placer.isSolid(xx, solidY, zz)) { - solidY++; - scan++; - } - int topBound = (scan < maxScan ? solidY - 1 : startY + Math.min(maxScan, 8)) - jitter; - int total = topBound - startY; - for (int j = startY; j <= topBound; j++) { - if (scratch > 0 && total > 0) { - double ratio = (double) (j - startY) / total; - if (ratio > (1.0 - scratch)) { - long sh = ((long) xx * 341873128712L) ^ ((long) j * 132897987541L) ^ ((long) zz * 735791245321L); - double skipChance = (ratio - (1.0 - scratch)) / scratch; - if ((Math.abs(sh) % 1000) / 1000.0 < skipChance * 0.7) { - continue; - } - } - } - placer.set(xx, j, zz, normalizePlacedWaterlogging(d, placer, xx, j, zz, waterlogCandidate)); - } - } else { - int scan = 0; - int solidY = startY - 1; - while (scan < maxScan && !placer.isSolid(xx, solidY, zz)) { - solidY--; - scan++; - } - int bottomBound = (scan < maxScan ? solidY + 1 : startY - Math.min(maxScan, 8)) + jitter; - int total = startY - bottomBound; - for (int j = startY; j >= bottomBound; j--) { - if (scratch > 0 && total > 0) { - double ratio = (double) (startY - j) / total; - if (ratio > (1.0 - scratch)) { - long sh = ((long) xx * 341873128712L) ^ ((long) j * 132897987541L) ^ ((long) zz * 735791245321L); - double skipChance = (ratio - (1.0 - scratch)) / scratch; - if ((Math.abs(sh) % 1000) / 1000.0 < skipChance * 0.7) { - continue; - } - } - } - placer.set(xx, j, zz, normalizePlacedWaterlogging(d, placer, xx, j, zz, waterlogCandidate)); - } - } - continue; - } - - int highest = placer.getHighest(xx, zz, self.getLoader(), true); - - int lowerBound = highest - 1; - if (settings != null) { - lowerBound -= config.getStiltSettings().getOverStilt() - rng.i(0, config.getStiltSettings().getYRand()); - if (settings.getYMax() != 0) - lowerBound -= Math.min(config.getStiltSettings().getYMax() - (lowest + y - highest), 0); - } - - if (eroding) { - double dx = exact[index * 3] - erodeCentroidX; - double dz = exact[index * 3 + 2] - erodeCentroidZ; - double normalizedDist = Math.sqrt(dx * dx + dz * dz) / erodeMaxDist; - normalizedDist = Math.min(normalizedDist, 1.0); - int totalDepth = (lowest + y) - lowerBound; - int erodeDepth = (int) (totalDepth * Math.pow(1.0 - normalizedDist, 1.5)); - lowerBound = (lowest + y) - erodeDepth; - } - - for (int j = lowest + y; j > lowerBound; j--) { - NativeBlockState fluidState = placer.get(xx, j, zz); - if (B.isFluid(fluidState)) { - break; - } - if (eroding) { - int depth = (lowest + y) - j; - int totalDepth = (lowest + y) - lowerBound; - double depthRatio = totalDepth > 0 ? (double) depth / totalDepth : 0; - if (depthRatio > 0.4) { - long hash = ((long) (xx * 341873128712L) ^ ((long) j * 132897987541L) ^ ((long) zz * 735791245321L)); - double skipChance = (depthRatio - 0.4) / 0.6; - if ((Math.abs(hash) % 1000) / 1000.0 < skipChance * 0.7) { - continue; - } - } - } - - if (B.isVineBlock(d)) { - d = attachVineFaces(placer, d, xx, j, zz); - } - placer.set(xx, j, zz, normalizePlacedWaterlogging(d, placer, xx, j, zz, waterlogCandidate)); - } - } - } - } finally { - self.readLock.unlock(); - } - - if (vacuuming && vacuumLowest != Integer.MAX_VALUE && placer.getEngine() != null) { - vacuumTerrain(placer, config, x, z, placementBounds.minX(), placementBounds.maxX(), - placementBounds.minZ(), placementBounds.maxZ(), vacuumLowest, vacuumHighest); - } - - if (heightmap != null) { - RNG rngx = rng.nextParallelRNG(3468854); - - KList snowColumns = heightmap.k(); - snowColumns.sort(Comparator.comparingInt(Position2::getX).thenComparingInt(Position2::getZ)); - for (Position2 i : snowColumns) { - int vx = i.getX(); - int vy = heightmap.get(i); - int vz = i.getZ(); - - if (config.getSnow() > 0) { - int height = rngx.i(0, (int) (config.getSnow() * 7)); - placer.set(vx, vy + 1, vz, IrisObject.States.snowLayer(Math.max(Math.min(height, 7), 0))); - } - } - } - - return y; - } - - static boolean shouldPlaceObjectBlock(boolean rawStructurePiece, boolean air, boolean wouldReplace) { - return !wouldReplace && (rawStructurePiece || !air); - } - - private static NativeBlockState normalizePlacedWaterlogging(NativeBlockState state, IObjectPlacer placer, - int x, int y, int z, boolean enabled) { - if (state == null || !IrisProceduralBlocks.hasProperty(state, "waterlogged")) { - return state; - } - if (!enabled && !"true".equals(IrisProceduralBlocks.propertyValue(state, "waterlogged"))) { - return IrisProceduralBlocks.normalizeWaterlogging(state, null, false); - } - return IrisProceduralBlocks.normalizeWaterlogging(state, placer.get(x, y, z), enabled); - } - - private String[] resolveMarkers(IrisObjectPlacement config, RNG rng, int x, int yv, int z, IrisData rdata, - ObjectPlacementBake bake) { - self.readLock.lock(); - try { - VectorMap blocks = self.blocks; - KMap markers = new KMap<>(); - KList list = StreamSupport.stream(blocks.keys().spliterator(), false) - .collect(KList.collector()); - // Marker selection persists into the mantle, so it must be seed-deterministic. - // Derive a side stream keyed on the placement position instead of consuming the - // caller's rng: consuming draws there would shift every later placement in the - // chunk and invalidate existing worlds/goldenhashes. - RNG markerRng = rng.nextParallelRNG(((((long) x) << 32) | (z & 0xFFFFFFFFL)) * 31L + yv); - - int markerIndex = 0; - for (IrisObjectMarker j : config.getMarkers()) { - IrisMarker marker = self.getLoader().getMarkerLoader().load(j.getMarker()); - int markerSalt = markerIndex++; - - if (marker == null || marker.isCompatExcluded()) { - continue; - } - - int max = j.getMaximumMarkers(); - for (IrisBlockVector i : list.shuffleCopy(markerRng.nextParallelRNG(markerSalt))) { - if (max <= 0) { - break; - } - - NativeBlockState data = blocks.get(i); - if (data == null) { - continue; - } - - for (NativeBlockState k : j.getMark(rdata)) { - if (max <= 0) { - break; - } - - if (j.isExact() ? k.matches(data) : IrisObjectShaping.materialKey(k).equals(IrisObjectShaping.materialKey(data))) { - boolean a = !blocks.containsKey((IrisBlockVector) i.clone().add(new IrisBlockVector(0, 1, 0))); - boolean fff = !blocks.containsKey((IrisBlockVector) i.clone().add(new IrisBlockVector(0, 2, 0))); - - if (!marker.isEmptyAbove() || (a && fff)) { - markers.put(i, j.getMarker()); - max--; - } - } - } - } - } - - String[] resolved = new String[bake.count()]; - VectorMap.Cursor cursor = blocks.cursor(); - int index = 0; - while (index < resolved.length && cursor.next()) { - resolved[index++] = markers.get(cursor.key()); - } - return resolved; - } finally { - self.readLock.unlock(); - } - } - - private KList transformedPaintSupport(SpinKernel spin, IrisBlockVector translateOffset, - CNG warp, double half, int x, int y, int z) { - KList offsets = new KList<>(); - for (IrisBlockVector source : self.getSurfaceSupportOffsets()) { - IrisBlockVector offset = source.clone(); - spin.rotate(offset); - if (translateOffset != null) { - offset.add(translateOffset); - } - int worldX = x + (int) Math.round(offset.getX()); - int worldZ = z + (int) Math.round(offset.getZ()); - if (warp != null) { - worldX += warp.fitDouble(-half, half, offset.getX() + x, offset.getY() + y, offset.getZ() + z); - worldZ += warp.fitDouble(-half, half, offset.getZ() + z, offset.getY() + y, offset.getX() + x); - } - offsets.add(new IrisBlockVector(worldX - x, 0, worldZ - z)); - } - return offsets; - } - - private int minimumOccupiedY(SpinKernel spin) { - int minimumY = Integer.MAX_VALUE; - self.readLock.lock(); - try { - VectorMap.Cursor cursor = self.blocks.cursor(); - while (cursor.next()) { - NativeBlockState state = cursor.value(); - if (state == null || state.isAir()) { - continue; - } - IrisBlockVector position = cursor.key().clone(); - spin.rotate(position); - minimumY = Math.min(minimumY, (int) Math.round(position.getY())); - } - } finally { - self.readLock.unlock(); - } - return minimumY; - } - - private TransformedBounds transformedBounds(SpinKernel spin, boolean translating, IrisBlockVector translateOffset, - boolean ceilingHang, int margin) { - int sourceMinX = -self.getCenter().getBlockX(); - int sourceMaxX = self.getW() - self.getCenter().getBlockX() - 1; - int sourceMinY = -self.getCenter().getBlockY(); - int sourceMaxY = self.getH() - self.getCenter().getBlockY() - 1; - int sourceMinZ = -self.getCenter().getBlockZ(); - int sourceMaxZ = self.getD() - self.getCenter().getBlockZ() - 1; - int minX = Integer.MAX_VALUE; - int maxX = Integer.MIN_VALUE; - int minY = Integer.MAX_VALUE; - int maxY = Integer.MIN_VALUE; - int minZ = Integer.MAX_VALUE; - int maxZ = Integer.MIN_VALUE; - - for (int xCorner = 0; xCorner < 2; xCorner++) { - int sourceX = xCorner == 0 ? sourceMinX : sourceMaxX; - for (int yCorner = 0; yCorner < 2; yCorner++) { - int sourceY = yCorner == 0 ? sourceMinY : sourceMaxY; - for (int zCorner = 0; zCorner < 2; zCorner++) { - int sourceZ = zCorner == 0 ? sourceMinZ : sourceMaxZ; - IrisBlockVector corner = new IrisBlockVector(sourceX, sourceY, sourceZ); - spin.rotate(corner); - if (ceilingHang) { - corner.setY(-corner.getBlockY()); - } - if (translating) { - corner.add(translateOffset); - } - int transformedX = (int) Math.round(corner.getX()); - int transformedY = (int) Math.round(corner.getY()); - int transformedZ = (int) Math.round(corner.getZ()); - minX = Math.min(minX, transformedX); - maxX = Math.max(maxX, transformedX); - minY = Math.min(minY, transformedY); - maxY = Math.max(maxY, transformedY); - minZ = Math.min(minZ, transformedZ); - maxZ = Math.max(maxZ, transformedZ); - } - } - } - - return new TransformedBounds(minX - margin, maxX + margin, minY, maxY, minZ - margin, maxZ + margin); - } - - private WorldBounds resolveWorldBounds(TransformedBounds bounds, IrisPaintSurfaceProjection projection, - int randomY, int x, int y, int z) { - int minX = x + bounds.minX(); - int maxX = x + bounds.maxX(); - int minZ = z + bounds.minZ(); - int maxZ = z + bounds.maxZ(); - if (projection == null) { - return new WorldBounds(minX, maxX, y + bounds.minY(), y + bounds.maxY(), minZ, maxZ); - } - - int minimumSurface = Integer.MAX_VALUE; - int maximumSurface = Integer.MIN_VALUE; - for (int worldX = minX; worldX <= maxX; worldX++) { - for (int worldZ = minZ; worldZ <= maxZ; worldZ++) { - int surface = projection.surfaceY(worldX, worldZ); - if (surface == IrisPaintSurfaceProjection.MISSING) { - continue; - } - minimumSurface = Math.min(minimumSurface, surface); - maximumSurface = Math.max(maximumSurface, surface); - } - } - int paintOffset = Math.floorDiv(self.h, 2) + randomY; - return new WorldBounds(minX, maxX, minimumSurface + bounds.minY() + paintOffset, - maximumSurface + bounds.maxY() + paintOffset, minZ, maxZ); - } - - private static Engine requireSlopeEngine(IObjectPlacer placer) { - return requirePlacementEngine(placer, "slope settings"); - } - - private static Engine requirePlacementEngine(IObjectPlacer placer, String feature) { - Engine engine = placer.getEngine(); - if (engine == null) { - throw new IllegalStateException("Object placement requires an active Iris engine for " + feature + "."); - } - return engine; - } - - private static Engine requireDataEngine(IrisData data, String feature) { - Engine engine = data.getEngine(); - if (engine == null) { - throw new IllegalStateException("Object placement requires active Iris data for " + feature + "."); - } - return engine; - } - - /** - * Objects may never intersect a native structure piece. The rect query is the fast path: no native structures - * near this placement means no per block work at all. Only when the placement envelope meets a piece does the - * precise pass run, and the first solid block inside a piece rejects the whole object before any write. - */ - private boolean nativeStructureVetoes(IObjectPlacer placer, IrisObjectPlacement config, SpinKernel spin, TransformedBounds bounds, - boolean translating, IrisBlockVector translateOffset, boolean ceilingHang, - IrisPaintSurfaceProjection projection, CNG surfaceWarp, double warpHalf, - int randomY, int warpMargin, int x, int y, int z) { - Engine engine = placer.getEngine(); - if (engine == null) { - return false; - } - - int minX = x + bounds.minX(); - int maxX = x + bounds.maxX(); - int minZ = z + bounds.minZ(); - int maxZ = z + bounds.maxZ(); - KList volumes = engine.getNativeStructureVolumes(minX, minZ, maxX, maxZ); - if (volumes == null || volumes.isEmpty()) { - return false; - } - - int worldY = y + engine.getMinHeight(); - int envelopeMinY = projection != null ? Integer.MIN_VALUE : worldY + bounds.minY() - warpMargin; - int envelopeMaxY = projection != null ? Integer.MAX_VALUE : worldY + bounds.maxY() + warpMargin; - boolean envelopeMeetsPiece = false; - boolean includesSurfaceCuts = false; - for (NativeStructureVolume volume : volumes) { - if (volume.intersects(minX, envelopeMinY, minZ, maxX, envelopeMaxY, maxZ)) { - envelopeMeetsPiece = true; - } - if (volume.surfaceAdjustment() != null && volume.intersectsInfluenceRect(minX, minZ, maxX, maxZ)) { - envelopeMeetsPiece = true; - includesSurfaceCuts = true; - } - } - - if (!envelopeMeetsPiece) { - return false; - } - - self.readLock.lock(); - try { - Long2LongOpenHashMap surfaceCuts = null; - VectorMap.Cursor cursor = self.blocks.cursor(); - while (cursor.next()) { - NativeBlockState state = cursor.value(); - if (state == null || isAirBlock(state)) { - continue; - } - - IrisBlockVector i = cursor.key().clone(); - spin.rotate(i); - if (ceilingHang) { - i.setY(-i.getBlockY()); - } - if (translating) { - i.add(translateOffset); - } - - int xx = x + (int) Math.round(i.getX()); - int zz = z + (int) Math.round(i.getZ()); - int yy = y + (int) Math.round(i.getY()); - if (projection != null) { - if (surfaceWarp != null) { - xx += surfaceWarp.fitDouble(-warpHalf, warpHalf, i.getX() + x, i.getY() + y, i.getZ() + z); - zz += surfaceWarp.fitDouble(-warpHalf, warpHalf, i.getZ() + z, i.getY() + y, i.getX() + x); - } - int surface = projection.surfaceY(xx, zz); - if (surface == IrisPaintSurfaceProjection.MISSING) { - continue; - } - if (!B.isVineBlock(state)) { - yy = (int) Math.round(i.getY()) + Math.floorDiv(self.h, 2) + surface + randomY; - } - } - int worldBlockY = yy + engine.getMinHeight(); - - for (NativeStructureVolume volume : volumes) { - if (volume.containsWithin(xx, worldBlockY, zz, projection == null ? warpMargin : 0)) { - return true; - } - } - if (!includesSurfaceCuts) { - continue; - } - if (surfaceCuts == null) { - surfaceCuts = new Long2LongOpenHashMap(); - surfaceCuts.defaultReturnValue(Long.MIN_VALUE); - } - long column = ((long) xx << 32) ^ (zz & 0xffffffffL); - long cut = surfaceCuts.get(column); - if (cut == Long.MIN_VALUE) { - int surfaceY = Engine.hostHeight(engine, xx, zz, true) + engine.getMinHeight(); - int targetY = NativeStructureVolume.surfaceCutY(volumes, xx, zz, surfaceY); - cut = ((long) surfaceY << 32) ^ (targetY & 0xffffffffL); - surfaceCuts.put(column, cut); - } - int surfaceY = (int) (cut >> 32); - int targetY = (int) cut; - int margin = projection == null ? warpMargin : 0; - if (targetY < surfaceY && worldBlockY > (long) targetY - margin - && worldBlockY <= (long) surfaceY + 1 + margin) { - return true; - } - } - } finally { - self.readLock.unlock(); - } - - return false; - } - - private static boolean isAirBlock(NativeBlockState state) { - String material = IrisObjectShaping.materialKey(state); - return material.equals("minecraft:air") || material.equals("minecraft:cave_air"); - } - - private void warnImplausibleBedrockPlacement(IObjectPlacer placer, IrisObjectPlacement config, int x, int y, int z) { - String key = self.getLoadKey(); - String fingerprint = (key == null ? "" : key) + "|" + config.getMode(); - long now = System.currentTimeMillis(); - Long last = IMPLAUSIBLE_BEDROCK_WARNS.get(fingerprint); - if (last != null && now - last < IMPLAUSIBLE_BEDROCK_WARN_THROTTLE_MS) { - return; - } - IMPLAUSIBLE_BEDROCK_WARNS.put(fingerprint, now); - IrisLogging.warn("Implausible object placement rejected: " - + (key == null ? "" : key) - + " resolved anchorY=" + y + " at (" + x + "," + z + ") mode=" + config.getMode() - + " carving=" + config.getCarvingSupport() - + ". Surface-anchored placement should never land on the bedrock row. " - + "Height sampling returned a bogus value — not configured for floor placement " - + "(forcePlace=false, fromBottom=false, mode!=FLOATING). Skipping to protect bedrock."); - } - - private void vacuumTerrain(IObjectPlacer placer, IrisObjectPlacement config, int centerX, int centerZ, int lowX, int highX, int lowZ, int highZ, int baseY, int topY) { - ObjectPlaceMode mode = config.getMode(); - IrisVacuumSettings settings = config.getVacuumSettings(); - int radius = IrisObjectVacuum.resolveRadius(mode, settings); - int step = IrisObjectVacuum.resolveStep(mode); - double falloff = IrisObjectVacuum.resolveFalloff(settings); - int jitter = settings != null ? Math.max(0, settings.getOrganicJitter()) : 4; - boolean organicEdge = mode == ObjectPlaceMode.VACUUM_ORGANIC; - boolean wavyEdge = mode == ObjectPlaceMode.VACUUM_WAVY; - double waveAmplitude = IrisObjectVacuum.resolveWaveAmplitude(settings); - double waveScale = IrisObjectVacuum.resolveWaveScale(settings); - SimplexNoise waveNoise = (wavyEdge && waveAmplitude > 0) ? new SimplexNoise(VACUUM_WAVE_SEED) : null; - int meetY = baseY - 1; - - IrisComplex complex = placer.getEngine().getComplex(); - int worldMin = placer.getEngine().getMinHeight(); - int worldMax = worldMin + placer.getEngine().getHeight() - 1; - - for (int dx = lowX - radius; dx <= highX + radius; dx += step) { - for (int dz = lowZ - radius; dz <= highZ + radius; dz += step) { - int cx = centerX + dx; - int cz = centerZ + dz; - double effRadius = radius; - if (organicEdge && jitter > 0) { - long h = ((long) cx * 341873128712L) ^ ((long) cz * 132897987541L); - double n = ((Math.abs(h) % 1000) / 1000.0) - 0.5; - effRadius = Math.max(1.0, radius + (n * 2.0 * jitter)); - } - int origY = placer.getHighest(cx, cz, self.getLoader(), true); - int targetY = IrisObjectVacuum.columnTargetY(dx, dz, lowX, highX, lowZ, highZ, effRadius, falloff, origY, meetY); - if (waveNoise != null) { - int outX = IrisObjectVacuum.outset(dx, lowX, highX); - int outZ = IrisObjectVacuum.outset(dz, lowZ, highZ); - double waveDistance = Math.sqrt((double) (outX * outX) + (double) (outZ * outZ)); - double sample = waveNoise.noiseSigned(cx * waveScale, cz * waveScale); - targetY += IrisObjectVacuum.waveOffset(waveDistance, effRadius, sample, waveAmplitude); - } - if (targetY == origY) { - continue; - } - targetY = Math.max(worldMin + 1, Math.min(worldMax, targetY)); - if (targetY > origY) { - NativeBlockState fill = complex != null ? complex.getRockStream().get(cx, cz) : null; - if (B.isAir(fill)) { - fill = IrisObject.States.stone(); - } - for (int yy = origY + 1; yy <= targetY; yy++) { - placer.set(cx, yy, cz, fill); - } - } else if (targetY < origY) { - boolean inside = IrisObjectVacuum.outset(dx, lowX, highX) == 0 && IrisObjectVacuum.outset(dz, lowZ, highZ) == 0; - int carveFloor = IrisObjectVacuum.carveFloorY(targetY, topY, inside); - for (int yy = origY; yy >= carveFloor; yy--) { - placer.set(cx, yy, cz, IrisObject.States.air()); - } - } - } - } - } - - private boolean shouldBailForCarvingAnchor(IObjectPlacer placer, IrisObjectPlacement placement, int x, int y, int z) { - CarvingMode carvingMode = placement.getCarvingSupport(); - return switch (carvingMode) { - case SURFACE_ONLY -> placer.isCarved(x, y, z); - case CARVING_ONLY -> !isCarvedCaveAnchor(placer, x, y, z); - case ANYWHERE -> false; - }; - } - - private boolean isCarvedCaveAnchor(IObjectPlacer placer, int x, int y, int z) { - return placer.isCarved(x, y, z) - || placer.isCarved(x, y - 1, z) - || placer.isCarved(x, y - 2, z) - || placer.isCarved(x, y - 3, z); - } - - private static NativeBlockState attachVineFaces(IObjectPlacer placer, NativeBlockState data, int x, int y, int z) { - NativeBlockState result = data; - for (String face : IrisProceduralBlocks.FACE_PROPERTIES) { - if (!IrisProceduralBlocks.hasProperty(data, face)) { - continue; - } - int[] mod = IrisProceduralBlocks.faceOffset(face); - NativeBlockState facing = placer.get(x + mod[0], y + mod[1], z + mod[2]); - if (B.isSolid(facing) && !B.isVineBlock(facing)) { - result = result.withProperty(face, "true"); - } - } - return result; - } - - private record StateFacts(NativeBlockState rotated, boolean waterloggable, boolean authoredWaterlogged, - NativeBlockState dry, boolean air) { - private static final StateFacts ROTATED_AWAY = new StateFacts(null, false, false, null, false); - } - - private static final class PlacementStates { - private static final Object ROTATED_AWAY = new Object(); - private static final byte UNKNOWN = 0; - private static final byte NO = 1; - private static final byte YES = 2; - - private final NativeBlockState[] palette; - private final IrisObjectRotation rotation; - private final int spinx; - private final int spiny; - private final int spinz; - private final boolean preventingDecay; - private final boolean debugSmartBore; - private final Set excludedStiltMaterials; - private final NativeBlockState[] decayed; - private final StateFacts[] facts; - private final byte[] stiltLayers; - private final byte[] stilts; - private final NativeBlockState[] stiltDefaults; - private IdentityHashMap rotations; - private IdentityHashMap editedFacts; - private IdentityHashMap placedStilts; - - private PlacementStates(NativeBlockState[] palette, IrisObjectRotation rotation, int spinx, int spiny, int spinz, - boolean preventingDecay, boolean debugSmartBore, Set excludedStiltMaterials) { - this.palette = palette; - this.rotation = rotation; - this.spinx = spinx; - this.spiny = spiny; - this.spinz = spinz; - this.preventingDecay = preventingDecay; - this.debugSmartBore = debugSmartBore; - this.excludedStiltMaterials = excludedStiltMaterials; - this.decayed = new NativeBlockState[palette.length]; - this.facts = new StateFacts[palette.length]; - this.stiltLayers = new byte[palette.length]; - this.stilts = new byte[palette.length]; - this.stiltDefaults = new NativeBlockState[palette.length]; - } - - private NativeBlockState source(int entry) { - NativeBlockState state = palette[entry]; - return debugSmartBore && IrisObject.States.vair().equals(state) ? IrisObject.States.vairDebug() : state; - } - - private boolean stiltLayer(int entry) { - byte known = stiltLayers[entry]; - if (known == UNKNOWN) { - known = IrisObjectShaping.isStiltLayerBlock(source(entry)) ? YES : NO; - stiltLayers[entry] = known; - } - return known == YES; - } - - private NativeBlockState decayed(int entry) { - NativeBlockState resolved = decayed[entry]; - if (resolved == null) { - NativeBlockState data = source(entry); - resolved = preventingDecay && IrisProceduralBlocks.hasProperty(data, "distance") - && "false".equals(IrisProceduralBlocks.propertyValue(data, "persistent")) - ? data.withProperty("persistent", "true") : data; - decayed[entry] = resolved; - } - return resolved; - } - - private StateFacts facts(int entry) { - StateFacts resolved = facts[entry]; - if (resolved == null) { - resolved = computeFacts(decayed(entry)); - facts[entry] = resolved; - } - return resolved; - } - - private StateFacts factsOf(NativeBlockState state) { - if (editedFacts == null) { - editedFacts = new IdentityHashMap<>(); - } - StateFacts resolved = editedFacts.get(state); - if (resolved == null) { - resolved = computeFacts(state); - editedFacts.put(state, resolved); - } - return resolved; - } - - private StateFacts computeFacts(NativeBlockState state) { - NativeBlockState rotated = rotate(state); - if (rotated == null) { - return StateFacts.ROTATED_AWAY; - } - boolean waterloggable = IrisProceduralBlocks.hasProperty(rotated, "waterlogged"); - boolean authored = waterloggable && "true".equals(IrisProceduralBlocks.propertyValue(rotated, "waterlogged")); - NativeBlockState dry = waterloggable ? IrisProceduralBlocks.normalizeWaterlogging(rotated, null, false) : rotated; - String material = IrisObjectShaping.materialKey(rotated); - boolean air = material.equals("minecraft:air") || material.equals("minecraft:cave_air"); - return new StateFacts(rotated, waterloggable, authored, dry, air); - } - - private NativeBlockState rotate(NativeBlockState state) { - if (state == null) { - return null; - } - if (rotations == null) { - rotations = new IdentityHashMap<>(); - } - Object memo = rotations.get(state); - if (memo == null) { - NativeBlockState rotated = rotation.rotate(state, spinx, spiny, spinz); - memo = rotated == null ? ROTATED_AWAY : rotated; - rotations.put(state, memo); - } - return memo == ROTATED_AWAY ? null : (NativeBlockState) memo; - } - - private boolean shouldStilt(int entry) { - byte known = stilts[entry]; - if (known == UNKNOWN) { - known = IrisObjectShaping.shouldStilt(palette[entry], excludedStiltMaterials) ? YES : NO; - stilts[entry] = known; - } - return known == YES; - } - - private NativeBlockState stiltDefault(int entry) { - NativeBlockState resolved = stiltDefaults[entry]; - if (resolved == null) { - NativeBlockState state = palette[entry]; - String material = IrisObjectShaping.materialKey(state); - resolved = material.equals("minecraft:grass_block") || material.equals("minecraft:mycelium") - || material.equals("minecraft:podzol") || material.equals("minecraft:dirt_path") - ? IrisObject.States.dirt() : state; - stiltDefaults[entry] = resolved; - } - return resolved; - } - - private boolean shouldStiltPlaced(NativeBlockState state) { - if (placedStilts == null) { - placedStilts = new IdentityHashMap<>(); - } - Boolean known = placedStilts.get(state); - if (known == null) { - known = IrisObjectShaping.shouldStilt(state, excludedStiltMaterials); - placedStilts.put(state, known); - } - return known; - } - } - - private record TransformedBounds(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) { - } - - private record WorldBounds(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) { + int place(int x, int yv, int z, IObjectPlacer placer, IrisObjectPlacement config, RNG rng, + BiConsumer listener, CarveResult cave, IrisData data) { + PlacementContext context = new PlacementContext(config, data, placer, cave, rng, self, alignment, x, z, yv, listener); + return PlacementLifecycles.forPlacer(placer).get(config.getMode()).execute(context).heightOrRejected(); } } diff --git a/core/src/main/java/art/arcane/iris/structure/object/ObjectBlockPass.java b/core/src/main/java/art/arcane/iris/structure/object/ObjectBlockPass.java new file mode 100644 index 000000000..e864c0ca8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/ObjectBlockPass.java @@ -0,0 +1,221 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.block.BlockDataMergeSupport; +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.decoration.IrisProceduralBlocks; + +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.iris.generation.block.B; +import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.math.BlockPosition; +import art.arcane.volmlib.util.matter.MatterMarker; + +import java.util.Optional; +import java.util.function.BiConsumer; + +final class ObjectBlockPass { + + private final BiConsumer listener; + + private final PlacementContext context; + private final PlacementState state; + private final ObjectPlacementBake bake; + private final PlacementPalette palette; + private final String[] markers; + private final PlacementLifecycle lifecycle; + + ObjectBlockPass( + PlacementContext context, + PlacementState state, + PlacementLifecycle lifecycle, + BiConsumer listener + ) { + this.context = context; + this.state = state; + this.lifecycle = lifecycle; + this.listener = listener; + this.bake = state.bake; + this.palette = state.palette; + this.markers = state.config.getMarkers().isNotEmpty() && state.placer.getEngine() != null + ? ObjectMarkerPlacement.resolve(context, state.config, bake) : null; + } + + void process() { + boolean warped = !state.config.getWarp().isFlat(); + boolean hasEdits = !state.config.getEdit().isEmpty(); + CNG surfaceWarp = warped ? state.config.getSurfaceWarp(context.rng, context.self.getLoader()) : null; + double warpHalf = warped ? state.config.getWarp().getMultiplier() / 2D : 0D; + boolean waterlogCandidate = state.config.isWaterloggable() || state.config.isUnderwater(); + double[] exact = warped || hasEdits ? bake.exact(context.self, state.spin) : null; + int[] tileBlocks = bake.tileBlocks(); + TileData[] tileValues = bake.tileValues(); + int tileCursor = 0; + int count = bake.count(); + + for (int index = 0; index < count; index++) { + int entry = bake.paletteIndex(index); + TileData tile = null; + if (tileBlocks != null && tileCursor < tileBlocks.length && tileBlocks[tileCursor] == index) { + tile = tileValues[tileCursor++]; + } + + NativeBlockState data = palette.decayed(entry); + boolean edited = false; + if (hasEdits) { + EditResult result = applyEdits(exact, index, data, tile); + data = result.data(); + tile = result.tile(); + edited = result.edited(); + } + + PlacementPalette.Facts facts = edited ? palette.factsOf(data) : palette.facts(entry); + NativeBlockState rotated = facts.rotated(); + if (rotated == null) { + continue; + } + int xx = context.x + bake.offsetX(index); + int yy = state.y + bake.offsetY(index); + int zz = context.z + bake.offsetZ(index); + + if (warped) { + double exactX = exact[index * 3]; + double exactY = exact[index * 3 + 1]; + double exactZ = exact[index * 3 + 2]; + xx += surfaceWarp.fitDouble(-warpHalf, warpHalf, exactX + context.x, exactY + state.y, exactZ + context.z); + zz += surfaceWarp.fitDouble(-warpHalf, warpHalf, exactZ + context.z, exactY + state.y, exactX + context.x); + } + + if (state.paintProjection != null) { + int surface = state.paintProjection.surfaceY(xx, zz); + if (surface == IrisPaintSurfaceProjection.MISSING) { + continue; + } + if (!B.isVineBlock(rotated)) { + yy = bake.offsetY(index) + Math.floorDiv(context.self.h, 2) + surface + state.yrand; + } + } + + if (state.config.isMeld() && !is(ObjectPlaceMode.STRUCTURE_PIECE) && !state.placer.isSolid(xx, yy, zz)) { + continue; + } + + boolean canopy = bake.canopy(index); + if (canopy && !state.placer.beginTreeCanopy(new BlockPosition(xx, yy, zz))) { + continue; + } + + try { + data = rotated; + if (facts.waterloggable()) { + data = waterlogCandidate || facts.authoredWaterlogged() + ? IrisProceduralBlocks.normalizeWaterlogging(data, state.placer.get(xx, yy, zz), waterlogCandidate) + : facts.dry(); + } + + if (!is(ObjectPlaceMode.STRUCTURE_PIECE) && B.isVineBlock(data)) { + data = attachVineFaces(state.placer, data, xx, yy, zz); + } + + String marker = markers == null ? null : markers[index]; + if (handleCustomData(data, facts.air(), tile, marker, xx, yy, zz)) { + lifecycle.run(PlacementStage.BLOCK_WRITTEN, context, state, new PlacementBlockWrite(xx, yy, zz, data)); + if (state.isRejected()) { + return; + } + } + } finally { + if (canopy) { + state.placer.endTreeCanopy(); + } + } + } + } + + private boolean is(ObjectPlaceMode mode) { + return state.config.getMode() == mode; + } + + static boolean shouldPlaceObjectBlock(boolean rawStructurePiece, boolean air, boolean wouldReplace) { + return !wouldReplace && (rawStructurePiece || !air); + } + + private boolean handleCustomData(NativeBlockState data, boolean air, TileData tile, String marker, int xx, int yy, int zz) { + // Short-circuit order matters for cost only: the mantle read is paid solely + // for vine blocks. Both operands are pure, so the value is unchanged. + boolean wouldReplace = !is(ObjectPlaceMode.STRUCTURE_PIECE) && B.isVineBlock(data) && B.isSolid(state.placer.get(xx, yy, zz)); + boolean place = shouldPlaceObjectBlock(is(ObjectPlaceMode.STRUCTURE_PIECE), air, wouldReplace); + if (data.isCustom() || place) { + state.placer.set(xx, yy, zz, data); + if (tile != null) { + state.placer.setTile(xx, yy, zz, tile); + } + if (marker != null) { + state.placer.setData(xx, yy, zz, new MatterMarker(marker)); + } + if (listener != null) { + listener.accept(new BlockPosition(xx, yy, zz), data); + } + return true; + } + return false; + } + + private EditResult applyEdits(double[] exact, int index, NativeBlockState data, TileData tile) { + double exactX = exact[index * 3]; + double exactY = exact[index * 3 + 1]; + double exactZ = exact[index * 3 + 2]; + boolean edited = false; + for (IrisObjectReplace j : state.config.getEdit()) { + if (context.rng.chance(j.getChance())) { + for (NativeBlockState k : j.getFind(context.rdata)) { + if (j.isExact() ? k.matches(data) : IrisObjectShaping.materialKey(k).equals(IrisObjectShaping.materialKey(data))) { + NativeBlockState newData = j.getReplace(context.rng, exactX + context.x, exactY + state.y, exactZ + context.z, context.rdata); + + boolean sameMaterial = IrisObjectShaping.materialKey(newData).equals(IrisObjectShaping.materialKey(data)); + if (sameMaterial && !(newData.isCustom() || data.isCustom())) + data = BlockDataMergeSupport.merge(data, newData); + else + data = newData; + edited = true; + + Optional t = j.getReplace().getTile(context.rng, context.x, state.y, context.z, context.rdata); + if (t.isPresent()) { + tile = t.get(); + } else if (!sameMaterial) { + tile = null; + } + } + } + } + } + return new EditResult(data, tile, edited); + } + + static NativeBlockState normalizePlacedWaterlogging(NativeBlockState state, IObjectPlacer placer, + int x, int y, int z, boolean enabled) { + if (state == null || !IrisProceduralBlocks.hasProperty(state, "waterlogged")) { + return state; + } + if (!enabled && !"true".equals(IrisProceduralBlocks.propertyValue(state, "waterlogged"))) { + return IrisProceduralBlocks.normalizeWaterlogging(state, null, false); + } + return IrisProceduralBlocks.normalizeWaterlogging(state, placer.get(x, y, z), enabled); + } + + static NativeBlockState attachVineFaces(IObjectPlacer placer, NativeBlockState data, int x, int y, int z) { + NativeBlockState result = data; + for (String face : IrisProceduralBlocks.FACE_PROPERTIES) { + if (!IrisProceduralBlocks.hasProperty(data, face)) { + continue; + } + int[] mod = IrisProceduralBlocks.faceOffset(face); + NativeBlockState facing = placer.get(x + mod[0], y + mod[1], z + mod[2]); + if (B.isSolid(facing) && !B.isVineBlock(facing)) { + result = result.withProperty(face, "true"); + } + } + return result; + } + + private record EditResult(NativeBlockState data, TileData tile, boolean edited) {} +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/ObjectMarkerPlacement.java b/core/src/main/java/art/arcane/iris/structure/object/ObjectMarkerPlacement.java new file mode 100644 index 000000000..a11bf20f4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/ObjectMarkerPlacement.java @@ -0,0 +1,78 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.world.entity.IrisMarker; + +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.iris.generation.block.VectorMap; +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.math.RNG; + +import java.util.stream.StreamSupport; + +/** Chooses marker positions from the object's own blocks, indexed like the placement bake. */ +final class ObjectMarkerPlacement { + private ObjectMarkerPlacement() { + } + + /** Callers hold the object read lock, so the source cursor matches the bake order. */ + static String[] resolve(PlacementContext context, IrisObjectPlacement config, ObjectPlacementBake bake) { + IrisObject self = context.self; + VectorMap blocks = self.blocks; + KMap markers = new KMap<>(); + KList list = StreamSupport.stream(blocks.keys().spliterator(), false) + .collect(KList.collector()); + // Marker selection persists into the mantle, so it must be seed-deterministic. + // Derive a side stream keyed on the placement position instead of consuming the + // caller's rng: consuming draws there would shift every later placement in the + // chunk and invalidate existing worlds/goldenhashes. + RNG markerRng = context.rng.nextParallelRNG(((((long) context.x) << 32) | (context.z & 0xFFFFFFFFL)) * 31L + context.yv); + + int markerIndex = 0; + for (IrisObjectMarker j : config.getMarkers()) { + IrisMarker marker = self.getLoader().getMarkerLoader().load(j.getMarker()); + int markerSalt = markerIndex++; + + if (marker == null || marker.isCompatExcluded()) { + continue; + } + + int max = j.getMaximumMarkers(); + for (IrisBlockVector i : list.shuffleCopy(markerRng.nextParallelRNG(markerSalt))) { + if (max <= 0) { + break; + } + + NativeBlockState data = blocks.get(i); + if (data == null) { + continue; + } + + for (NativeBlockState k : j.getMark(context.rdata)) { + if (max <= 0) { + break; + } + + if (j.isExact() ? k.matches(data) : IrisObjectShaping.materialKey(k).equals(IrisObjectShaping.materialKey(data))) { + boolean firstAboveEmpty = !blocks.containsKey((IrisBlockVector) i.clone().add(new IrisBlockVector(0, 1, 0))); + boolean secondAboveEmpty = !blocks.containsKey((IrisBlockVector) i.clone().add(new IrisBlockVector(0, 2, 0))); + + if (!marker.isEmptyAbove() || (firstAboveEmpty && secondAboveEmpty)) { + markers.put(i, j.getMarker()); + max--; + } + } + } + } + } + + String[] resolved = new String[bake.count()]; + VectorMap.Cursor cursor = blocks.cursor(); + int index = 0; + while (index < resolved.length && cursor.next()) { + resolved[index++] = markers.get(cursor.key()); + } + return resolved; + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/ObjectPlaceMode.java b/core/src/main/java/art/arcane/iris/structure/object/ObjectPlaceMode.java index ba6ef9f8d..e1c89b8e7 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/ObjectPlaceMode.java +++ b/core/src/main/java/art/arcane/iris/structure/object/ObjectPlaceMode.java @@ -18,91 +18,135 @@ package art.arcane.iris.structure.object; + import art.arcane.volmlib.util.documentation.Description; +import java.util.function.Supplier; + @Description("Object Place modes are useful for positioning objects just right. The default value is CENTER_HEIGHT.") public enum ObjectPlaceMode { @Description("The default place mode. This mode picks a center point (where the center of the object will be) and takes the height. That height is used for the whole object.") - CENTER_HEIGHT, + CENTER_HEIGHT(PlacementModuleCenterHeight::new), @Description("Samples a lot of points where the object will cover (horizontally) and picks the highest height, that height is then used to place the object. This mode is useful for preventing any part of your object from being buried though it will float off of cliffs.") - MAX_HEIGHT, + MAX_HEIGHT(() -> new PlacementModuleHeight(PlacementModuleHeight.Mode.MAX, false)), @Description("Samples only 4 points where the object will cover (horizontally) and picks the highest height, that height is then used to place the object. This mode is useful for preventing any part of your object from being buried though it will float off of cliffs.\"") - FAST_MAX_HEIGHT, + FAST_MAX_HEIGHT(() -> new PlacementModuleHeight(PlacementModuleHeight.Mode.MAX, true)), @Description("Samples a lot of points where the object will cover (horizontally) and picks the lowest height, that height is then used to place the object. This mode is useful for preventing any part of your object from overhanging a cliff though it gets buried a lot") - - MIN_HEIGHT, + MIN_HEIGHT(() -> new PlacementModuleHeight(PlacementModuleHeight.Mode.MIN, false)), @Description("Samples only 4 points where the object will cover (horizontally) and picks the lowest height, that height is then used to place the object. This mode is useful for preventing any part of your object from overhanging a cliff though it gets buried a lot") - FAST_MIN_HEIGHT, + FAST_MIN_HEIGHT(() -> new PlacementModuleHeight(PlacementModuleHeight.Mode.MIN, true)), @Description("Stilting is MAX_HEIGHT but it repeats the bottom most block of your object until it hits the surface. This is expensive because it has to first sample every height value for each x,z position of your object. Avoid using this unless its structures for performance reasons.") - STILT, + STILT(() -> new PlacementModuleStilt(PlacementModuleStilt.Mode.DEFAULT, false)), @Description("Just like stilting but very inaccurate. Useful for stilting a lot of objects without too much care on accuracy (you can use the over-stilt value to force stilts under ground further)") - FAST_STILT, + FAST_STILT(() -> new PlacementModuleStilt(PlacementModuleStilt.Mode.DEFAULT, true)), @Description("Stilting is MIN_HEIGHT but it repeats the bottom most block of your object until it hits the surface. This is expensive because it has to first sample every height value for each x,z position of your object. Avoid using this unless its structures for performance reasons.") - MIN_STILT, + MIN_STILT(() -> new PlacementModuleStilt(PlacementModuleStilt.Mode.MIN, false)), @Description("Just like MIN_STILT but very inaccurate. Useful for stilting a lot of objects without too much care on accuracy (you can use the over-stilt value to force stilts under ground further)") - FAST_MIN_STILT, + FAST_MIN_STILT(() -> new PlacementModuleStilt(PlacementModuleStilt.Mode.MIN, true)), @Description("Stilting is CENTER_HEIGHT but it repeats the bottom most block of your object until it hits the surface. This is expensive because it has to first sample every height value for each x,z position of your object. Avoid using this unless its structures for performance reasons.") - CENTER_STILT, + CENTER_STILT(() -> new PlacementModuleStilt(PlacementModuleStilt.Mode.CENTER, false)), @Description("Erode stilting tapers columns downward like an ice cream cone. Blocks near the center extend deepest while edge blocks drop off first. Blocks are randomly skipped in the lower portion for a rough organic eroded texture.") - ERODE_STILT, + ERODE_STILT(() -> new PlacementModuleStilt(PlacementModuleStilt.Mode.ERODE, false)), @Description("Organic stilting scans straight down from the bottom of the object to the first solid block (the cave floor, or terrain) and fills the gap with the object's own bottom block. Each column stops at a slightly different, noise-varied depth and the lower portion is randomly broken up, so the underside reads as rough organic roots rather than a flat disc. Designed for objects placed inside caves so they connect to the floor instead of floating. Tune with stilt-settings (organicMaxScan, organicJitter, organicScratch).") - ORGANIC_STILT, + ORGANIC_STILT(() -> new PlacementModuleStilt(PlacementModuleStilt.Mode.ORGANIC, false)), @Description("Hangs the object upside-down from a cave ceiling. The object is flipped vertically (its tip points down like a stalactite) and its top is anchored to the ceiling, while an organic, noise-varied stilt grows up into the ceiling so it never floats below the roof. Use inside caves; tune with stilt-settings (organicMaxScan, organicJitter, organicScratch).") - CEILING_HANG, + CEILING_HANG(() -> new PlacementModuleStilt(PlacementModuleStilt.Mode.CEILING, false)), @Description("Anchors the object at the terrain surface (plus translate.y) like CENTER_HEIGHT, then bends the surrounding terrain to be flush with the object's base. Columns inside the object footprint are raised (or lowered) to meet the base, and the deformation falls off parabolically out to a radius so the terrain smoothly blends back to its natural height. Use this to seat a floating/translated object on the ground without a flat stilt disc, e.g. a cube translated up 10 on a mountain pulls the terrain up to meet it. Tune with vacuum-settings (radius, falloff).") - VACUUM, + VACUUM(PlacementModuleCenterHeight::new), @Description("VACUUM with a larger radius and finer per-column sampling for the smoothest terrain blend. More expensive.") - VACUUM_HIGH, + VACUUM_HIGH(PlacementModuleCenterHeight::new), @Description("VACUUM with a smaller radius and coarser sampling for performance. Use when seating many objects.") - VACUUM_FAST, + VACUUM_FAST(PlacementModuleCenterHeight::new), @Description("VACUUM whose falloff radius is perturbed by noise per column, so the terrain meets the object with an irregular organic edge rather than a clean parabolic bowl.") - VACUUM_ORGANIC, + VACUUM_ORGANIC(PlacementModuleCenterHeight::new), @Description("VACUUM whose terrain bend is bent by smooth coherent simplex noise so the surface meets the object with a gently rolling, wavy slope instead of the ragged white-noise edge of VACUUM_ORGANIC or a clean parabolic bowl. The object itself still seats flush; the waves fade to zero under the object and at the outer rim, peaking across the mid-slope. Tune with vacuum-settings (waveAmplitude, waveScale).") - VACUUM_WAVY, + VACUUM_WAVY(PlacementModuleCenterHeight::new), @Description("Samples the height of the terrain at every x,z position of your object and pushes it down to the surface. It's pretty much like a melt function over the terrain.") - PAINT, + PAINT(PlacementModulePaint::new), @Description("Places the object in pure air at an absolute Y driven entirely by translate.y (plus optional translate.yRandom). Terrain height, underwater, and carving anchor checks are skipped. Use this for floating islands, sky structures, clouds, or blimps where the object must not be translated to the ground.") - FLOATING, + FLOATING(PlacementModuleFloating::new), @Description("Raw stamp at the caller-supplied (x, y, z). No terrain sampling, no stilting, no Y recomputation, no underwater or carving anchor guards. Used internally to route native Minecraft structure pieces (villages etc.) through the Iris object placer.") - STRUCTURE_PIECE + STRUCTURE_PIECE(() -> lifecycle -> PlacementModuleAnchor.install(lifecycle, + (context, state, ignored) -> state.y = context.yv)); + + + private final Supplier factory; + + ObjectPlaceMode(Supplier factory) { + this.factory = factory; + } + + PlacementLifecycle createLifecycle() { + PlacementLifecycleBuilder lifecycle = new PlacementLifecycleBuilder(); + install(lifecycle); + return lifecycle.build(); + } + + void install(PlacementLifecycleBuilder lifecycle) { + new PlacementModulePreparation().install(lifecycle); + new PlacementModuleSlope().install(lifecycle); + factory.get().install(lifecycle); + new PlacementModuleAnchor().install(lifecycle); + PlacementModulePaint.installProjection(lifecycle); + new PlacementModuleSurfaceSupport().install(lifecycle); + new PlacementModuleLimits().install(lifecycle); + new PlacementModuleNativeStructureProtection().install(lifecycle); + new PlacementModuleBounds().install(lifecycle); + new PlacementModuleCollision().install(lifecycle); + new PlacementModuleTreeGrounding().install(lifecycle); + new PlacementModuleBore().install(lifecycle); + new PlacementModuleBlocks().install(lifecycle); + PlacementLifecycleDependency finish = switch (this) { + case STILT, FAST_STILT, MIN_STILT, FAST_MIN_STILT, CENTER_STILT, ERODE_STILT, ORGANIC_STILT, CEILING_HANG -> + PlacementModuleStilt.FILL; + case VACUUM, VACUUM_HIGH, VACUUM_FAST, VACUUM_ORGANIC, VACUUM_WAVY -> { + new PlacementModuleVacuum().install(lifecycle); + yield PlacementModuleVacuum.FINISH; + } + default -> PlacementModuleBlocks.COMPLETE; + }; + new PlacementModuleSnow(finish).install(lifecycle); + } + + } diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementBlockWrite.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementBlockWrite.java new file mode 100644 index 000000000..7ee7ef6ec --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementBlockWrite.java @@ -0,0 +1,6 @@ +package art.arcane.iris.structure.object; + +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; + +/** A completed object write, after its tile data and listener have been handled. */ +public record PlacementBlockWrite(int x, int y, int z, NativeBlockState data) {} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementContext.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementContext.java new file mode 100644 index 000000000..6f78923fb --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementContext.java @@ -0,0 +1,55 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.cave.CarveResult; +import art.arcane.iris.structure.object.PlacementContext.TransformedBounds; +import art.arcane.iris.structure.object.PlacementContext.WorldBounds; + +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.BlockPosition; +import art.arcane.volmlib.util.math.RNG; +import lombok.Getter; + +import java.util.Objects; +import java.util.function.BiConsumer; + +/** Caller inputs for one placement. Modules keep derived values in PlacementState. */ +@Getter +public final class PlacementContext { + public record TransformedBounds(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) {} + public record WorldBounds(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) {} + + final IrisObjectPlacement config; + final IrisData rdata; + final IObjectPlacer placer; + final CarveResult caveResult; + final RNG rng; + final IrisObject self; + final IrisObjectPlacementAlignment alignment; + final int x; + final int z; + final int yv; + final BiConsumer listener; + + public PlacementContext(IrisObjectPlacement config, IrisData rdata, IObjectPlacer placer, + CarveResult caveResult, RNG rng, IrisObject self, int x, int z, int yv, + BiConsumer listener) { + this(config, rdata, placer, caveResult, rng, self, IrisObjectPlacementAlignment.CENTER, x, z, yv, listener); + } + + public PlacementContext(IrisObjectPlacement config, IrisData rdata, IObjectPlacer placer, + CarveResult caveResult, RNG rng, IrisObject self, IrisObjectPlacementAlignment alignment, + int x, int z, int yv, BiConsumer listener) { + this.placer = Objects.requireNonNull(placer, "Object placer is required."); + this.config = Objects.requireNonNull(config, "Object placement config is required."); + this.rng = Objects.requireNonNull(rng, "Object placement RNG is required."); + this.rdata = Objects.requireNonNull(rdata, "Object placement data is required."); + this.self = Objects.requireNonNull(self, "Object is required."); + this.alignment = Objects.requireNonNull(alignment, "Object placement alignment is required."); + this.caveResult = caveResult; + this.x = x; + this.z = z; + this.yv = yv; + this.listener = listener; + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementFunction.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementFunction.java new file mode 100644 index 000000000..af82f4a1b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementFunction.java @@ -0,0 +1,7 @@ +package art.arcane.iris.structure.object; + + +@FunctionalInterface +public interface PlacementFunction { + void execute(PlacementContext context, PlacementState state, C input); +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementLifecycle.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementLifecycle.java new file mode 100644 index 000000000..abb9efb08 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementLifecycle.java @@ -0,0 +1,45 @@ +package art.arcane.iris.structure.object; + + +import java.util.List; + +/** Immutable plan. Placement performs no registration, graph traversal, or sorting. */ +public final class PlacementLifecycle { + private final List>> stages; + + PlacementLifecycle(List> ordered) { + stages = PlacementStage.ALL.stream() + .map(stage -> ordered.stream().filter(step -> step.stage() == stage).toList()).toList(); + } + + /** The block pass and everything after it read one consistent object snapshot. */ + public PlacementState execute(PlacementContext context) { + PlacementState state = new PlacementState(context); + for (PlacementStage stage : PlacementStage.BEFORE_PASS) { + run(stage, context, state, null); + } + if (state.isRejected()) { + return state; + } + context.self.readLock.lock(); + try { + run(PlacementStage.BLOCKS, context, state, this); + run(PlacementStage.AFTER_BLOCKS, context, state, null); + } finally { + context.self.readLock.unlock(); + } + return state; + } + + public void run(PlacementStage stage, PlacementContext context, PlacementState state, C input) { + for (PlacementLifecycleBuilder.Step step : stages.get(stage.order)) { + if (state.isRejected()) { + return; + } + // Registration pairs each function with its typed stage. + @SuppressWarnings("unchecked") + PlacementFunction function = (PlacementFunction) step.function(); + function.execute(context, state, input); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementLifecycleBuilder.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementLifecycleBuilder.java new file mode 100644 index 000000000..c9de11d42 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementLifecycleBuilder.java @@ -0,0 +1,85 @@ +package art.arcane.iris.structure.object; + + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.PriorityQueue; + +/** + * Registers steps and resolves their graph before an engine runtime is published. + * A dependency names a required step, not a Java class: two instances of the same module can + * register distinct work. Declaring a dependency does not install its prerequisite. + * The module composition must register it, in any order, before calling {@link #build()}. + * + *
{@code
+ * lifecycle.add(FILL, PlacementStage.AFTER_BLOCKS, this::fill, HEIGHT, BOUNDS);
+ * }
+ * + * Independent steps retain registration order within their stage. Invalid graphs fail construction; + * the builder never executes functions. Functions must not capture mutable placement state. + */ +public final class PlacementLifecycleBuilder { + private final Map> steps = new LinkedHashMap<>(); + + public void add(PlacementLifecycleDependency id, PlacementStage stage, + PlacementFunction function, PlacementLifecycleDependency... after) { + Objects.requireNonNull(id, "Step id"); + Objects.requireNonNull(stage, "Stage"); + Objects.requireNonNull(function, "Function"); + Step step = new Step<>(id, stage, function, List.of(after), steps.size()); + if (steps.putIfAbsent(id, step) != null) { + throw new IllegalArgumentException("Duplicate placement step '" + id.name() + "'"); + } + } + + /** Stable topological sort; stage barriers do not need all-to-all edges. */ + public PlacementLifecycle build() { + Map remaining = new HashMap<>(); + Map>> dependents = new HashMap<>(); + PriorityQueue> ready = new PriorityQueue<>(Comparator.>comparingInt(step -> step.stage.order).thenComparingInt(Step::index)); + for (Step step : steps.values()) { + var prerequisites = step.after.stream().distinct().toList(); + remaining.put(step.id, prerequisites.size()); + for (PlacementLifecycleDependency id : prerequisites) { + Step prerequisite = steps.get(id); + if (prerequisite == null) { + throw new IllegalArgumentException("Placement step '" + step.id.name() + + "' requires missing step '" + id.name() + "'"); + } + if (prerequisite.stage.order > step.stage.order) { + throw new IllegalArgumentException("Placement step '" + step.id.name() + "' in " + step.stage + + " cannot depend on later step '" + id.name() + "' in " + prerequisite.stage); + } + dependents.computeIfAbsent(id, ignored -> new ArrayList<>()).add(step); + } + if (prerequisites.isEmpty()) { + ready.add(step); + } + } + List> ordered = new ArrayList<>(steps.size()); + while (!ready.isEmpty()) { + Step step = ready.remove(); + ordered.add(step); + for (Step dependent : dependents.getOrDefault(step.id, List.of())) { + if (remaining.compute(dependent.id, (ignored, count) -> count - 1) == 0) { + ready.add(dependent); + } + } + } + if (ordered.size() != steps.size()) { + throw new IllegalArgumentException("Placement dependency cycle; unresolved steps: " + + steps.keySet().stream().filter(id -> remaining.get(id) > 0) + .map(PlacementLifecycleDependency::name).toList()); + } + return new PlacementLifecycle(ordered); + } + + record Step(PlacementLifecycleDependency id, PlacementStage stage, PlacementFunction function, + List after, int index) { + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementLifecycleDependency.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementLifecycleDependency.java new file mode 100644 index 000000000..e6b310fbf --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementLifecycleDependency.java @@ -0,0 +1,13 @@ +package art.arcane.iris.structure.object; + +import java.util.Objects; + +/** A named prerequisite, scoped to one placement plan. */ +public record PlacementLifecycleDependency(String name) { + public PlacementLifecycleDependency { + Objects.requireNonNull(name, "Dependency name"); + if (name.isBlank()) { + throw new IllegalArgumentException("Placement dependency name cannot be blank"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementLifecycles.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementLifecycles.java new file mode 100644 index 000000000..8d7bfb681 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementLifecycles.java @@ -0,0 +1,44 @@ +package art.arcane.iris.structure.object; + + +import java.util.EnumMap; +import java.util.Map; + +/** One resolved plan per mode in an engine runtime. Engine-less tools share a separate default set. */ +public final class PlacementLifecycles { + private final Map plans; + + private PlacementLifecycles(Map plans) { + this.plans = Map.copyOf(plans); + } + + public static PlacementLifecycles resolve() { + Map plans = new EnumMap<>(ObjectPlaceMode.class); + for (ObjectPlaceMode mode : ObjectPlaceMode.values()) { + try { + plans.put(mode, mode.createLifecycle()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid placement lifecycle for " + mode + ": " + e.getMessage(), e); + } + } + return new PlacementLifecycles(plans); + } + + public static PlacementLifecycles defaults() { + return Defaults.INSTANCE; + } + + static PlacementLifecycles forPlacer(IObjectPlacer placer) { + var engine = placer.getEngine(); + PlacementLifecycles plans = engine == null ? null : engine.getPlacementLifecycles(); + return plans == null ? defaults() : plans; + } + + public PlacementLifecycle get(ObjectPlaceMode mode) { + return plans.get(java.util.Objects.requireNonNull(mode, "Placement mode")); + } + + private static final class Defaults { + private static final PlacementLifecycles INSTANCE = resolve(); + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModule.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModule.java new file mode 100644 index 000000000..214461308 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModule.java @@ -0,0 +1,9 @@ +package art.arcane.iris.structure.object; + +/** + * Registers work during runtime construction. Functions are shared by concurrent placements: + * implementations must receive placement state at execution time, never capture it at installation. + */ +public interface PlacementModule { + void install(PlacementLifecycleBuilder lifecycle); +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleAnchor.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleAnchor.java new file mode 100644 index 000000000..652fea3b8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleAnchor.java @@ -0,0 +1,89 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.block.VectorMap; +import art.arcane.iris.generation.cave.CarvingMode; +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; + +/** Floor, caller-supplied and from-bottom anchors wrap each mode's sampled height. */ +final class PlacementModuleAnchor implements PlacementModule { + static final PlacementLifecycleDependency HEIGHT = new PlacementLifecycleDependency("anchor.height"); + static final PlacementLifecycleDependency POSITIONED = new PlacementLifecycleDependency("anchor.positioned"); + static final PlacementLifecycleDependency FINAL_HEIGHT = new PlacementLifecycleDependency("anchor.final"); + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + lifecycle.add(POSITIONED, PlacementStage.HEIGHT, (context, state, ignored) -> { + if (context.yv >= 0 && state.config.isBottom() + && state.config.getMode() != ObjectPlaceMode.STRUCTURE_PIECE + && context.alignment != IrisObjectPlacementAlignment.FLOOR) { + state.y += Math.floorDiv(context.self.h, 2); + if (!state.config.isForcePlace() && state.config.getCarvingSupport() != CarvingMode.CARVING_ONLY + && PlacementModuleSurfaceSupport.rejectsAnchor(context, state, context.x, state.y, context.z)) { + state.reject("carving-anchor"); + } + } + }, HEIGHT); + lifecycle.add(FINAL_HEIGHT, PlacementStage.BEFORE_BLOCKS, + (context, state, ignored) -> state.y += state.yrand, POSITIONED, PlacementModuleCollision.CHECK); + } + + static void install(PlacementLifecycleBuilder lifecycle, PlacementFunction sampledHeight) { + lifecycle.add(HEIGHT, PlacementStage.HEIGHT, (context, state, ignored) -> { + if (context.alignment == IrisObjectPlacementAlignment.FLOOR) { + alignToFloor(context, state); + return; + } + if (state.config.isFromBottom()) { + state.y = context.self.getH() + 1 + state.rty; + } else if (context.yv < 0) { + sampledHeight.execute(context, state, null); + return; + } else { + state.y = context.yv; + } + if (!state.config.isForcePlace() + && state.config.getMode() != ObjectPlaceMode.STRUCTURE_PIECE + && PlacementModuleSurfaceSupport.rejectsAnchor(context, state, context.x, state.y, context.z)) { + state.reject("carving-anchor"); + } + }, PlacementModulePreparation.TRANSFORMS); + } + + /** Seats the lowest occupied block on the caller's first air Y. */ + private static void alignToFloor(PlacementContext context, PlacementState state) { + if (context.yv < 0 || state.ceilingHang || state.config.getMode() == ObjectPlaceMode.STRUCTURE_PIECE) { + throw new IllegalArgumentException("Floor alignment requires an explicit floor Y and a floor placement mode."); + } + int minimumY = minimumOccupiedY(context.self, state.spin); + if (minimumY == Integer.MAX_VALUE) { + state.reject("empty-object"); + return; + } + state.y = context.yv - minimumY; + if (!state.config.isForcePlace() + && PlacementModuleSurfaceSupport.rejectsAnchor(context, state, context.x, context.yv, context.z)) { + state.reject("carving-anchor"); + } + } + + private static int minimumOccupiedY(IrisObject self, SpinKernel spin) { + int minimumY = Integer.MAX_VALUE; + self.readLock.lock(); + try { + VectorMap.Cursor cursor = self.blocks.cursor(); + while (cursor.next()) { + NativeBlockState state = cursor.value(); + if (state == null || state.isAir()) { + continue; + } + IrisBlockVector position = cursor.key().clone(); + spin.rotate(position); + minimumY = Math.min(minimumY, (int) Math.round(position.getY())); + } + } finally { + self.readLock.unlock(); + } + return minimumY; + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleBlocks.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleBlocks.java new file mode 100644 index 000000000..8355df58b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleBlocks.java @@ -0,0 +1,27 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.decoration.IrisStiltSettings; + +import java.util.Set; + +/** Resolves the baked object layout for this placement's transform, then runs the block pass. */ +final class PlacementModuleBlocks implements PlacementModule { + static final PlacementLifecycleDependency COMPLETE = new PlacementLifecycleDependency("blocks.complete"); + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + lifecycle.add(COMPLETE, PlacementStage.BLOCKS, (context, state, plan) -> { + bake(context, state); + new ObjectBlockPass(context, state, plan, context.listener).process(); + }, PlacementModuleBore.BORE); + } + + private static void bake(PlacementContext context, PlacementState state) { + state.bake = ObjectPlacementBake.of(context.self, state.spin, state.ceilingHang, state.translateOffset); + IrisStiltSettings stiltSettings = state.config.getStiltSettings(); + Set excludedStiltMaterials = stiltSettings == null ? Set.of() : stiltSettings.excludedMaterials(); + state.palette = new PlacementPalette(state.bake.palette(), state.config.getRotation(), + state.spinX, state.spinY, state.spinZ, state.placer.isPreventingDecay(), + state.placer.isDebugSmartBore(), excludedStiltMaterials); + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleBore.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleBore.java new file mode 100644 index 000000000..6a0acbb4f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleBore.java @@ -0,0 +1,31 @@ +package art.arcane.iris.structure.object; + +/** The first world-writing step; all placement guards must have completed. */ +final class PlacementModuleBore implements PlacementModule { + static final PlacementLifecycleDependency BORE = new PlacementLifecycleDependency("bore"); + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + lifecycle.add(BORE, PlacementStage.BEFORE_BLOCKS, (context, state, ignored) -> { + if (!state.config.isBore()) { + return; + } + var bounds = state.worldBounds; + for (int x = bounds.minX(); x <= bounds.maxX(); x++) { + for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) { + boolean paint = state.paintProjection != null; + int surface = paint ? state.paintProjection.surfaceY(x, z) : 0; + if (surface == IrisPaintSurfaceProjection.MISSING) { + continue; + } + int offset = Math.floorDiv(context.self.h, 2) + state.yrand; + int minimumY = paint ? surface + state.bounds.minY() + offset : bounds.minY(); + int maximumY = paint ? surface + state.bounds.maxY() + offset : bounds.maxY(); + for (int y = minimumY - state.config.getBoreExtendMinY(); y <= maximumY + state.config.getBoreExtendMaxY(); y++) { + state.placer.set(x, y, z, IrisObject.States.air()); + } + } + } + }, PlacementModuleBounds.WORLD, PlacementModuleCollision.CHECK, PlacementModuleAnchor.FINAL_HEIGHT); + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleBounds.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleBounds.java new file mode 100644 index 000000000..ce156bf5c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleBounds.java @@ -0,0 +1,46 @@ +package art.arcane.iris.structure.object; + +/** Computes the shared world volume for collision checks and boring, only when needed. */ +final class PlacementModuleBounds implements PlacementModule { + static final PlacementLifecycleDependency WORLD = new PlacementLifecycleDependency("bounds.world"); + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + lifecycle.add(WORLD, PlacementStage.VALIDATE, (context, state, ignored) -> { + IrisObjectPlacement config = state.config; + if (config.isBore() || (!config.isForcePlace() && config.getMode() != ObjectPlaceMode.STRUCTURE_PIECE + && (!config.getAllowedCollisions().isEmpty() || !config.getForbiddenCollisions().isEmpty()))) { + state.worldBounds = resolve(context, state, context.yv < 0 && config.getMode() == ObjectPlaceMode.PAINT); + } + }, PlacementModuleNativeStructureProtection.CHECK); + } + + private static PlacementContext.WorldBounds resolve(PlacementContext context, PlacementState state, boolean paint) { + int x = context.x; + int z = context.z; + int y = state.y + state.yrand; + int minX = x + state.bounds.minX(); + int maxX = x + state.bounds.maxX(); + int minZ = z + state.bounds.minZ(); + int maxZ = z + state.bounds.maxZ(); + if (!paint) { + return new PlacementContext.WorldBounds(minX, maxX, y + state.bounds.minY(), y + state.bounds.maxY(), minZ, maxZ); + } + + int minimumSurface = Integer.MAX_VALUE; + int maximumSurface = Integer.MIN_VALUE; + for (int worldX = minX; worldX <= maxX; worldX++) { + for (int worldZ = minZ; worldZ <= maxZ; worldZ++) { + int surface = state.paintProjection.surfaceY(worldX, worldZ); + if (surface == IrisPaintSurfaceProjection.MISSING) { + continue; + } + minimumSurface = Math.min(minimumSurface, surface); + maximumSurface = Math.max(maximumSurface, surface); + } + } + int paintOffset = Math.floorDiv(context.self.h, 2) + state.yrand; + return new PlacementContext.WorldBounds(minX, maxX, minimumSurface + state.bounds.minY() + paintOffset, + maximumSurface + state.bounds.maxY() + paintOffset, minZ, maxZ); + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleCenterHeight.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleCenterHeight.java new file mode 100644 index 000000000..4967ba78c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleCenterHeight.java @@ -0,0 +1,18 @@ +package art.arcane.iris.structure.object; + +public final class PlacementModuleCenterHeight implements PlacementModule { + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + PlacementModuleAnchor.install(lifecycle, PlacementModuleCenterHeight::sample); + } + + private static void sample(PlacementContext context, PlacementState state, Void ignored) { + state.y = (context.caveResult != null ? context.caveResult.getSurface() : state.placer.getHighest(context.x, context.z, context.self.getLoader(), state.config.isUnderwater())) + state.rty; + if (!state.config.isForcePlace()) { + if (PlacementModuleSurfaceSupport.rejectsAnchor(context, state, context.x, state.y, context.z)) { + state.reject("carving-anchor"); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleCollision.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleCollision.java new file mode 100644 index 000000000..2e8091b5a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleCollision.java @@ -0,0 +1,51 @@ +package art.arcane.iris.structure.object; + + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.structure.placement.PlacedObject; + +/** Owns object collision policy and its data-engine requirement. */ +final class PlacementModuleCollision implements PlacementModule { + static final PlacementLifecycleDependency CHECK = new PlacementLifecycleDependency("validate.collisions"); + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + lifecycle.add(CHECK, PlacementStage.VALIDATE, (context, state, ignored) -> { + IrisObjectPlacement config = state.config; + if (config.isForcePlace() || config.getMode() == ObjectPlaceMode.STRUCTURE_PIECE + || (config.getAllowedCollisions().isEmpty() && config.getForbiddenCollisions().isEmpty())) { + return; + } + Engine engine = context.rdata.getEngine(); + if (engine == null) { + throw new IllegalStateException("Object placement requires active Iris data for collision settings."); + } + var bounds = state.worldBounds; + for (int x = bounds.minX(); x <= bounds.maxX(); x++) { + for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) { + boolean paint = state.paintProjection != null; + int surface = paint ? state.paintProjection.surfaceY(x, z) : 0; + if (surface == IrisPaintSurfaceProjection.MISSING) { + continue; + } + int offset = Math.floorDiv(context.self.h, 2) + state.yrand; + int minimumY = paint ? surface + state.bounds.minY() + offset : bounds.minY(); + int maximumY = paint ? surface + state.bounds.maxY() + offset : bounds.maxY(); + for (int y = minimumY; y <= maximumY; y++) { + String marker = state.placer.getData(x, y, z, String.class); + PlacedObject placed = engine.resolveObjectPlacementMarker(x, z, marker); + if (placed == null || placed.getObject() == null) { + continue; + } + String key = placed.getObject().getLoadKey(); + if (key != null && config.getForbiddenCollisions().contains(key) + && !config.getAllowedCollisions().contains(key)) { + state.reject("object-collision"); + return; + } + } + } + } + }, PlacementModuleBounds.WORLD); + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleFloating.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleFloating.java new file mode 100644 index 000000000..e928e080d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleFloating.java @@ -0,0 +1,10 @@ +package art.arcane.iris.structure.object; + +public final class PlacementModuleFloating implements PlacementModule { + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + PlacementModuleAnchor.install(lifecycle, (context, state, ignored) -> state.y = state.rty); + } + +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleHeight.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleHeight.java new file mode 100644 index 000000000..dd43222e7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleHeight.java @@ -0,0 +1,58 @@ +package art.arcane.iris.structure.object; + + +public final class PlacementModuleHeight implements PlacementModule { + private final boolean fast; + private final Mode mode; + + public enum Mode { + MIN, + MAX + } + + public PlacementModuleHeight(Mode mode, boolean fast) { + this.fast = fast; + this.mode = mode; + } + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + PlacementModuleAnchor.install(lifecycle, this::sample); + } + + private void sample(PlacementContext context, PlacementState state, Void ignored) { + int minX = context.x + state.bounds.minX(); + int maxX = context.x + state.bounds.maxX(); + int minZ = context.z + state.bounds.minZ(); + int maxZ = context.z + state.bounds.maxZ(); + if (Mode.MIN == mode) { + state.y = Integer.MAX_VALUE; + } + int xStep = fast ? Math.max(0, (maxX - minX) / 2) + 1 : 1; + int zStep = fast ? Math.max(0, (maxZ - minZ) / 2) + 1 : 1; + for (int i = minX; i <= maxX; i += xStep) { + for (int ii = minZ; ii <= maxZ; ii += zStep) { + columnHeight(context, state, i, ii); + if (state.isRejected()) { + return; + } + } + } + } + + private void columnHeight(PlacementContext context, PlacementState state, int i, int ii) { + int h = state.placer.getHighest(i, ii, context.self.getLoader(), state.config.isUnderwater()) + state.rty; + if (!state.config.isForcePlace()) { + if (PlacementModuleSurfaceSupport.rejectsAnchor(context, state, i, h, ii)) { + state.reject("carving-anchor"); + } + } + + if (Mode.MIN == mode && h < state.y) { + state.y = h; + } + if (Mode.MAX == mode && h > state.y) { + state.y = h; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleLimits.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleLimits.java new file mode 100644 index 000000000..0458cfa7c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleLimits.java @@ -0,0 +1,34 @@ +package art.arcane.iris.structure.object; + +/** Water, cave clearance and height-clamp eligibility. */ +final class PlacementModuleLimits implements PlacementModule { + static final PlacementLifecycleDependency CHECK = new PlacementLifecycleDependency("validate.limits"); + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + lifecycle.add(CHECK, PlacementStage.VALIDATE, (context, state, ignored) -> { + IrisObjectPlacement config = state.config; + if (config.isForcePlace() || config.getMode() == ObjectPlaceMode.STRUCTURE_PIECE) { + return; + } + if (context.yv < 0 && config.getMode() != ObjectPlaceMode.FLOATING + && !config.isUnderwater() && !config.isOnwater() + && state.placer.isUnderwater(context.x, context.z)) { + state.reject("underwater-anchor"); + return; + } + if (context.caveResult != null + && Math.max(0, context.self.h + state.yrand + state.ty) + 1 >= context.caveResult.getHeight()) { + state.reject("cave-clearance"); + return; + } + if (config.isUnderwater() && state.y + state.rty + state.ty >= state.placer.getFluidHeight(context.x, context.z)) { + state.reject("fluid-height"); + return; + } + if (!config.getClamp().canPlace(state.y + state.rty + state.ty, state.y - state.rty + state.ty)) { + state.reject("height-clamp"); + } + }, PlacementModuleSurfaceSupport.CHECK); + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleNativeStructureProtection.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleNativeStructureProtection.java new file mode 100644 index 000000000..3f1e82010 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleNativeStructureProtection.java @@ -0,0 +1,168 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.block.B; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.iris.generation.block.VectorMap; +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.volmlib.util.collection.KList; +import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap; + +/** Native structures remain protected even when forcePlace bypasses optional placement checks. */ +final class PlacementModuleNativeStructureProtection implements PlacementModule { + static final PlacementLifecycleDependency CHECK = new PlacementLifecycleDependency("validate.native-structures"); + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + lifecycle.add(CHECK, PlacementStage.VALIDATE, (context, state, ignored) -> { + if (state.config.getMode() != ObjectPlaceMode.STRUCTURE_PIECE && vetoes(context, state)) { + state.reject("native-structure"); + } + }, PlacementModuleLimits.CHECK); + } + + private static boolean vetoes(PlacementContext context, PlacementState state) { + IrisObject self = context.self; + IObjectPlacer placer = state.placer; + IrisObjectPlacement config = state.config; + SpinKernel spin = state.spin; + IrisBlockVector translateOffset = state.translateOffset; + boolean translating = translateOffset != null; + boolean ceilingHang = state.ceilingHang; + boolean paint = context.yv < 0 && config.getMode() == ObjectPlaceMode.PAINT; + int warpMargin = state.warpMargin; + int x = context.x; + int y = state.y + state.yrand; + int z = context.z; + var bounds = state.bounds; + var projection = state.paintProjection; + var surfaceWarp = config.getWarp().isFlat() ? null : config.getSurfaceWarp(context.rng, self.getLoader()); + double warpHalf = config.getWarp().isFlat() ? 0 : config.getWarp().getMultiplier() / 2D; + int randomY = state.yrand; + Engine engine = placer.getEngine(); + if (engine == null) { + return false; + } + + int minX = x + bounds.minX(); + int maxX = x + bounds.maxX(); + int minZ = z + bounds.minZ(); + int maxZ = z + bounds.maxZ(); + KList volumes = engine.getNativeStructureVolumes(minX, minZ, maxX, maxZ); + if (volumes == null || volumes.isEmpty()) { + return false; + } + + int worldY = y + engine.getMinHeight(); + int envelopeMinY = projection != null ? Integer.MIN_VALUE : worldY + bounds.minY() - warpMargin; + int envelopeMaxY = projection != null ? Integer.MAX_VALUE : worldY + bounds.maxY() + warpMargin; + boolean envelopeMeetsPiece = false; + boolean includesSurfaceCuts = false; + for (NativeStructureVolume volume : volumes) { + if (volume.intersects(minX, envelopeMinY, minZ, maxX, envelopeMaxY, maxZ)) { + envelopeMeetsPiece = true; + } + if (volume.surfaceAdjustment() != null && volume.intersectsInfluenceRect(minX, minZ, maxX, maxZ)) { + envelopeMeetsPiece = true; + includesSurfaceCuts = true; + } + } + + if (!envelopeMeetsPiece) { + return false; + } + + int margin = projection == null ? warpMargin : 0; + SurfaceCuts surfaceCuts = includesSurfaceCuts ? new SurfaceCuts(engine, volumes) : null; + self.readLock.lock(); + try { + VectorMap.Cursor cursor = self.blocks.cursor(); + while (cursor.next()) { + NativeBlockState source = cursor.value(); + if (source == null || isAirBlock(source)) { + continue; + } + + IrisBlockVector i = cursor.key().clone(); + spin.rotate(i); + if (ceilingHang) { + i.setY(-i.getBlockY()); + } + if (translating) { + i.add(translateOffset); + } + + int xx = x + (int) Math.round(i.getX()); + int zz = z + (int) Math.round(i.getZ()); + int yy = y + (int) Math.round(i.getY()); + if (projection != null) { + if (surfaceWarp != null) { + xx += surfaceWarp.fitDouble(-warpHalf, warpHalf, i.getX() + x, i.getY() + y, i.getZ() + z); + zz += surfaceWarp.fitDouble(-warpHalf, warpHalf, i.getZ() + z, i.getY() + y, i.getX() + x); + } + int surface = projection.surfaceY(xx, zz); + if (surface == IrisPaintSurfaceProjection.MISSING) { + continue; + } + if (!B.isVineBlock(source)) { + yy = (int) Math.round(i.getY()) + Math.floorDiv(self.h, 2) + surface + randomY; + } + } + int worldBlockY = yy + engine.getMinHeight(); + + for (NativeStructureVolume volume : volumes) { + if (volume.containsWithin(xx, worldBlockY, zz, margin)) { + return true; + } + } + if (surfaceCuts != null && surfaceCuts.reaches(xx, worldBlockY, zz, margin)) { + return true; + } + } + } finally { + self.readLock.unlock(); + } + + return false; + } + + /** Terrain a native structure lowers is protected between the cut floor and the original surface. */ + private static final class SurfaceCuts { + private final Engine engine; + private final KList volumes; + private final Long2LongOpenHashMap columns = new Long2LongOpenHashMap(); + + private SurfaceCuts(Engine engine, KList volumes) { + this.engine = engine; + this.volumes = volumes; + columns.defaultReturnValue(Long.MIN_VALUE); + } + + private boolean reaches(int x, int worldY, int z, int margin) { + long cut = cutOf(x, z); + int surfaceY = (int) (cut >> 32); + int targetY = (int) cut; + return targetY < surfaceY && worldY > (long) targetY - margin && worldY <= (long) surfaceY + 1 + margin; + } + + private long cutOf(int x, int z) { + long column = ((long) x << 32) ^ (z & 0xffffffffL); + long cut = columns.get(column); + if (cut == Long.MIN_VALUE) { + int surfaceY = Engine.hostHeight(engine, x, z, true) + engine.getMinHeight(); + int targetY = NativeStructureVolume.surfaceCutY(volumes, x, z, surfaceY); + cut = ((long) surfaceY << 32) ^ (targetY & 0xffffffffL); + columns.put(column, cut); + } + return cut; + } + } + + private static boolean isAirBlock(NativeBlockState state) { + String material = IrisObjectShaping.materialKey(state); + return material.equals("minecraft:air") || material.equals("minecraft:cave_air"); + } + +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModulePaint.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModulePaint.java new file mode 100644 index 000000000..f27cd782d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModulePaint.java @@ -0,0 +1,70 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.noise.CNG; + +public final class PlacementModulePaint implements PlacementModule { + + static final PlacementLifecycleDependency PROJECT = new PlacementLifecycleDependency("paint.project"); + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + PlacementModuleAnchor.install(lifecycle, PlacementModulePaint::sample); + } + + private static void sample(PlacementContext context, PlacementState state, Void ignored) { + int anchorX = context.x + (state.translateOffset == null ? 0 : (int) Math.round(state.translateOffset.getX())); + int anchorZ = context.z + (state.translateOffset == null ? 0 : (int) Math.round(state.translateOffset.getZ())); + state.y = (context.caveResult != null ? context.caveResult.getSurface() + : state.placer.getHighest(anchorX, anchorZ, context.self.getLoader(), state.config.isUnderwater())) + state.rty; + if (!state.config.isForcePlace()) { + if (PlacementModuleSurfaceSupport.rejectsAnchor(context, state, context.x, state.y, context.z)) { + state.reject("carving-anchor"); + } + } + } + static void installProjection(PlacementLifecycleBuilder lifecycle) { + lifecycle.add(PROJECT, PlacementStage.VALIDATE, (context, state, ignored) -> { + if (context.yv >= 0 || state.config.getMode() != ObjectPlaceMode.PAINT) { + return; + } + int buffer = state.config.isRequireSurfaceSupport() ? Math.max(0, state.config.getSurfaceSupportBuffer()) : 0; + int anchorX = context.x + (state.translateOffset == null ? 0 : (int) Math.round(state.translateOffset.getX())); + int anchorZ = context.z + (state.translateOffset == null ? 0 : (int) Math.round(state.translateOffset.getZ())); + state.paintProjection = IrisPaintSurfaceProjection.create(state.placer, new IrisPaintSurfaceProjection.Request( + context.self.getLoader(), context.x + state.bounds.minX() - buffer, context.x + state.bounds.maxX() + buffer, + context.z + state.bounds.minZ() - buffer, context.z + state.bounds.maxZ() + buffer, + anchorX, state.y - state.rty, anchorZ, state.config.isUnderwater(), state.config.getHeightmap() != null)); + if (state.paintProjection == null) { + state.reject("paint-projection"); + return; + } + var warp = state.config.getWarp().isFlat() ? null : state.config.getSurfaceWarp(context.rng, context.self.getLoader()); + double half = state.config.getWarp().isFlat() ? 0 : state.config.getWarp().getMultiplier() / 2D; + state.paintSupport = transformedPaintSupport(context.self, state.spin, state.translateOffset, warp, half, + context.x, state.y + state.yrand, context.z); + }, PlacementModuleAnchor.POSITIONED); + } + + private static KList transformedPaintSupport(IrisObject self, SpinKernel spin, IrisBlockVector translateOffset, + CNG warp, double half, int x, int y, int z) { + KList offsets = new KList<>(); + for (IrisBlockVector source : self.getSurfaceSupportOffsets()) { + IrisBlockVector offset = source.clone(); + spin.rotate(offset); + if (translateOffset != null) { + offset.add(translateOffset); + } + int worldX = x + (int) Math.round(offset.getX()); + int worldZ = z + (int) Math.round(offset.getZ()); + if (warp != null) { + worldX += warp.fitDouble(-half, half, offset.getX() + x, offset.getY() + y, offset.getZ() + z); + worldZ += warp.fitDouble(-half, half, offset.getZ() + z, offset.getY() + y, offset.getX() + x); + } + offsets.add(new IrisBlockVector(worldX - x, 0, worldZ - z)); + } + return offsets; + } + +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModulePreparation.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModulePreparation.java new file mode 100644 index 000000000..789011cb1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModulePreparation.java @@ -0,0 +1,94 @@ +package art.arcane.iris.structure.object; + + +import art.arcane.iris.structure.object.HeightmapObjectPlacer; +import art.arcane.iris.generation.geometry.IrisBlockVector; + +/** Resolves the placer and transforms once, preserving the caller's RNG draw order. */ +final class PlacementModulePreparation implements PlacementModule { + static final PlacementLifecycleDependency PLACER = new PlacementLifecycleDependency("prepare.placer"); + static final PlacementLifecycleDependency TRANSFORMS = new PlacementLifecycleDependency("prepare.transforms"); + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + lifecycle.add(PLACER, PlacementStage.PREPARE, (context, state, ignored) -> { + if (state.config.getHeightmap() != null) { + state.placer = new HeightmapObjectPlacer(context.rng, context.x, context.yv, context.z, + state.config, context.placer); + } + }); + lifecycle.add(TRANSFORMS, PlacementStage.PREPARE, PlacementModulePreparation::prepare, + PlacementModuleSlope.SLOPE); + } + + private static void prepare(PlacementContext context, PlacementState state, Void ignored) { + IrisObjectPlacement config = state.config; + IrisObject self = context.self; + int spinx = context.rng.imax() / 1000; + int spiny = context.rng.imax() / 1000; + int spinz = context.rng.imax() / 1000; + state.spinX = spinx; + state.spinY = spiny; + state.spinZ = spinz; + state.rty = config.getRotation().rotate(new IrisBlockVector(0, self.getCenter().getBlockY(), 0), spinx, spiny, spinz).getBlockY(); + state.ty = config.getTranslate().translate(new IrisBlockVector(0, self.getCenter().getBlockY(), 0), config.getRotation(), spinx, spiny, spinz).getBlockY(); + state.spin = new SpinKernel(config.getRotation(), spinx, spiny, spinz); + state.ceilingHang = config.getMode() == ObjectPlaceMode.CEILING_HANG; + IrisObjectTranslate translate = config.getTranslate(); + if (translate.canTranslate()) { + state.translateOffset = config.getRotation().rotate( + new IrisBlockVector(translate.getX(), translate.getY(), translate.getZ()), spinx, spiny, spinz); + } + int yrand = translate.getYRandom(); + state.yrand = yrand > 0 ? context.rng.i(0, yrand) : yrand < 0 ? context.rng.i(yrand, 0) : yrand; + state.warpMargin = !config.getWarp().isFlat() ? (int) Math.ceil(Math.abs(config.getWarp().getMultiplier()) / 2D) : 0; + state.bounds = transformedBounds(self, state.spin, state.translateOffset != null, state.translateOffset, + state.ceilingHang, state.warpMargin); + } + + private static PlacementContext.TransformedBounds transformedBounds(IrisObject self, SpinKernel spin, boolean translating, IrisBlockVector translateOffset, + boolean ceilingHang, int margin) { + int sourceMinX = -self.getCenter().getBlockX(); + int sourceMaxX = self.getW() - self.getCenter().getBlockX() - 1; + int sourceMinY = -self.getCenter().getBlockY(); + int sourceMaxY = self.getH() - self.getCenter().getBlockY() - 1; + int sourceMinZ = -self.getCenter().getBlockZ(); + int sourceMaxZ = self.getD() - self.getCenter().getBlockZ() - 1; + int minX = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int minY = Integer.MAX_VALUE; + int maxY = Integer.MIN_VALUE; + int minZ = Integer.MAX_VALUE; + int maxZ = Integer.MIN_VALUE; + + for (int xCorner = 0; xCorner < 2; xCorner++) { + int sourceX = xCorner == 0 ? sourceMinX : sourceMaxX; + for (int yCorner = 0; yCorner < 2; yCorner++) { + int sourceY = yCorner == 0 ? sourceMinY : sourceMaxY; + for (int zCorner = 0; zCorner < 2; zCorner++) { + int sourceZ = zCorner == 0 ? sourceMinZ : sourceMaxZ; + IrisBlockVector corner = new IrisBlockVector(sourceX, sourceY, sourceZ); + spin.rotate(corner); + if (ceilingHang) { + corner.setY(-corner.getBlockY()); + } + if (translating) { + corner.add(translateOffset); + } + int transformedX = (int) Math.round(corner.getX()); + int transformedY = (int) Math.round(corner.getY()); + int transformedZ = (int) Math.round(corner.getZ()); + minX = Math.min(minX, transformedX); + maxX = Math.max(maxX, transformedX); + minY = Math.min(minY, transformedY); + maxY = Math.max(maxY, transformedY); + minZ = Math.min(minZ, transformedZ); + maxZ = Math.max(maxZ, transformedZ); + } + } + } + + return new PlacementContext.TransformedBounds(minX - margin, maxX + margin, minY, maxY, minZ - margin, maxZ + margin); + } + +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleSlope.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleSlope.java new file mode 100644 index 000000000..06993aae5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleSlope.java @@ -0,0 +1,70 @@ +package art.arcane.iris.structure.object; + + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.volmlib.util.stream.ProceduralStream; + +/** Slope checks precede random rotation and leave the caller's configuration intact. */ +final class PlacementModuleSlope implements PlacementModule { + static final PlacementLifecycleDependency SLOPE = new PlacementLifecycleDependency("prepare.slope"); + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + lifecycle.add(SLOPE, PlacementStage.PREPARE, (context, state, ignored) -> { + IrisObjectPlacement config = state.config; + boolean evaluate = !config.isForcePlace() && !config.getSlopeCondition().isDefault(); + if (!evaluate && !config.isRotateTowardsSlope()) { + return; + } + Engine engine = state.placer.getEngine(); + if (engine == null) { + throw new IllegalStateException("Object placement requires an active Iris engine for slope settings."); + } + if (evaluate && !config.getSlopeCondition().isValid( + engine.getComplex().getSlopeStream().get(context.x, context.z))) { + state.reject("slope"); + return; + } + if (config.isRotateTowardsSlope()) { + state.config = rotate(context.self, config, engine, context.x, context.z); + } + }, PlacementModulePreparation.PLACER); + } + + private static IrisObjectPlacement rotate(IrisObject self, IrisObjectPlacement config, Engine engine, int x, int z) { + int slopeRotationY = 0; + ProceduralStream heightStream = engine.getComplex().getHeightStream(); + // Whichever side of the rectangle that bounds the object is lowest is the 'direction' of the slope (simply said). + double hNorth = heightStream.get(x, z + ((float) self.d) / 2); + double hEast = heightStream.get(x + ((float) self.w) / 2, z); + double hSouth = heightStream.get(x, z - ((float) self.d) / 2); + double hWest = heightStream.get(x - ((float) self.w) / 2, z); + double min = Math.min(Math.min(hNorth, hEast), Math.min(hSouth, hWest)); + if (min == hNorth) { + slopeRotationY = 0; + } else if (min == hEast) { + slopeRotationY = 90; + } else if (min == hSouth) { + slopeRotationY = 180; + } else if (min == hWest) { + slopeRotationY = 270; + } + + double newRotation = config.getRotation().getYAxis().getMin() + slopeRotationY; + IrisObjectRotation originalRotation = config.getRotation(); + IrisObjectRotation slopeRotation = new IrisObjectRotation(); + slopeRotation.setXAxis(originalRotation.getXAxis()); + slopeRotation.setZAxis(originalRotation.getZAxis()); + if (newRotation == 0) { + slopeRotation.setYAxis(new IrisAxisRotationClamp(false, false, 0, 0, 90)); + slopeRotation.setEnabled(originalRotation.canRotateX() || originalRotation.canRotateZ()); + } else { + slopeRotation.setYAxis(new IrisAxisRotationClamp(true, false, newRotation, newRotation, 90)); + slopeRotation.setEnabled(true); + } + config = config.toPlacement(config.getPlace().toArray(new String[0])); + config.setRotation(slopeRotation); + return config; + } + +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleSnow.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleSnow.java new file mode 100644 index 000000000..d1ba6f3ed --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleSnow.java @@ -0,0 +1,46 @@ +package art.arcane.iris.structure.object; + +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.math.Position2; +import art.arcane.volmlib.util.math.RNG; + +/** Snow uses only successful object writes, and follows stilt or vacuum work. */ +final class PlacementModuleSnow implements PlacementModule { + static final PlacementLifecycleDependency COLLECT = new PlacementLifecycleDependency("snow.collect"); + static final PlacementLifecycleDependency FINISH = new PlacementLifecycleDependency("snow.finish"); + private final PlacementLifecycleDependency after; + + PlacementModuleSnow(PlacementLifecycleDependency after) { + this.after = after; + } + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + lifecycle.add(COLLECT, PlacementStage.BLOCK_WRITTEN, (context, state, block) -> { + if (!(state.config.getSnow() > 0)) { + return; + } + if (state.snowHeights == null) { + state.snowHeights = new KMap<>(); + } + Position2 position = new Position2(block.x(), block.z()); + Integer current = state.snowHeights.get(position); + if (current == null || current < block.y()) { + state.snowHeights.put(position, block.y()); + } + }); + lifecycle.add(FINISH, PlacementStage.AFTER_BLOCKS, (context, state, ignored) -> { + if (state.snowHeights == null) { + return; + } + RNG rng = context.rng.nextParallelRNG(3468854); + var columns = state.snowHeights.k(); + columns.sort(java.util.Comparator.comparingInt(Position2::getX).thenComparingInt(Position2::getZ)); + for (Position2 position : columns) { + int height = rng.i(0, (int) (state.config.getSnow() * 7)); + state.placer.set(position.getX(), state.snowHeights.get(position) + 1, position.getZ(), + IrisObject.States.snowLayer(Math.max(Math.min(height, 7), 0))); + } + }, COLLECT, after); + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleStilt.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleStilt.java new file mode 100644 index 000000000..e39db190f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleStilt.java @@ -0,0 +1,283 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.block.BlockDataMergeSupport; +import art.arcane.iris.generation.decoration.IrisStiltSettings; + +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.iris.generation.block.B; +import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.math.RNG; + +/** Repeats the object's stilt layer down to the ground, or up into the ceiling, after the block pass. */ +public final class PlacementModuleStilt implements PlacementModule { + static final PlacementLifecycleDependency FILL = new PlacementLifecycleDependency("stilt.fill"); + + private final boolean fast; + private final Mode mode; + + public enum Mode { + DEFAULT, CENTER, MIN, ERODE, ORGANIC, CEILING + } + + private record StiltLayers(int lowest, int highest) { + boolean isEmpty() { + return lowest == Integer.MAX_VALUE; + } + } + + public PlacementModuleStilt(Mode mode, boolean fast) { + this.mode = mode; + this.fast = fast; + } + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + PlacementModule height = switch (mode) { + case CENTER, ORGANIC, CEILING -> new PlacementModuleCenterHeight(); + case MIN -> new PlacementModuleHeight(PlacementModuleHeight.Mode.MIN, fast); + case DEFAULT -> new PlacementModuleHeight(PlacementModuleHeight.Mode.MAX, fast); + case ERODE -> PlacementModuleStilt::installUnsampledHeight; + }; + height.install(lifecycle); + lifecycle.add(FILL, PlacementStage.AFTER_BLOCKS, this::fill, PlacementModuleAnchor.HEIGHT, + PlacementModuleBlocks.COMPLETE, PlacementModuleTreeGrounding.ROOTS); + } + + /** The legacy runner never sampled terrain for ERODE_STILT, so surface placements keep the unset height. */ + private static void installUnsampledHeight(PlacementLifecycleBuilder lifecycle) { + PlacementModuleAnchor.install(lifecycle, (context, state, ignored) -> { + }); + } + + private static StiltLayers stiltLayers(ObjectPlacementBake bake, PlacementPalette palette) { + int lowest = Integer.MAX_VALUE; + int highest = Integer.MIN_VALUE; + for (int index = 0; index < bake.count(); index++) { + if (palette.stiltLayer(bake.paletteIndex(index))) { + int layer = bake.layerY(index); + lowest = Math.min(lowest, layer); + highest = Math.max(highest, layer); + } + } + return new StiltLayers(lowest, highest); + } + + private void fill(PlacementContext context, PlacementState state, Void ignored) { + if (state.grounding != null) { + return; + } + ObjectPlacementBake bake = state.bake; + PlacementPalette palette = state.palette; + StiltLayers layers = stiltLayers(bake, palette); + if (layers.isEmpty()) { + return; + } + IrisObject self = context.self; + IrisObjectPlacement config = state.config; + IObjectPlacer placer = state.placer; + IrisData rdata = context.rdata; + RNG rng = context.rng; + boolean ceilingHang = mode == Mode.CEILING; + boolean organic = mode == Mode.ORGANIC || ceilingHang; + boolean eroding = mode == Mode.ERODE; + boolean hasEdits = !config.getEdit().isEmpty(); + boolean warped = !config.getWarp().isFlat(); + boolean waterlogCandidate = config.isWaterloggable() || config.isUnderwater(); + CNG surfaceWarp = warped ? config.getSurfaceWarp(rng, self.getLoader()) : null; + double warpHalf = warped ? config.getWarp().getMultiplier() / 2D : 0D; + double[] exact = warped || hasEdits || eroding ? bake.exact(self, state.spin) : null; + int count = bake.count(); + int x = context.x; + int z = context.z; + int y = state.y; + int lowest = layers.lowest(); + int topLayer = layers.highest(); + int xx, zz; + IrisStiltSettings settings = config.getStiltSettings(); + + double erodeCentroidX = 0; + double erodeCentroidZ = 0; + double erodeMaxDist = 1; + if (eroding) { + int centroidCount = 0; + for (int index = 0; index < count; index++) { + if (bake.layerY(index) == lowest && palette.shouldStilt(bake.paletteIndex(index))) { + erodeCentroidX += exact[index * 3]; + erodeCentroidZ += exact[index * 3 + 2]; + centroidCount++; + } + } + if (centroidCount > 0) { + erodeCentroidX /= centroidCount; + erodeCentroidZ /= centroidCount; + } + for (int index = 0; index < count; index++) { + if (bake.layerY(index) == lowest && palette.shouldStilt(bake.paletteIndex(index))) { + double dx = exact[index * 3] - erodeCentroidX; + double dz = exact[index * 3 + 2] - erodeCentroidZ; + double dist = Math.sqrt(dx * dx + dz * dz); + if (dist > erodeMaxDist) { + erodeMaxDist = dist; + } + } + } + } + + int targetLayer = ceilingHang ? topLayer : lowest; + for (int index = 0; index < count; index++) { + int entry = bake.paletteIndex(index); + if (!palette.shouldStilt(entry)) { + continue; + } + + NativeBlockState d; + if (settings != null && settings.getPalette() != null) { + d = settings.getPalette().get(rng, x, y, z, rdata); + } else { + d = palette.stiltDefault(entry); + } + + if (bake.layerY(index) != targetLayer) + continue; + d = palette.rotate(d); + + if (hasEdits) { + double exactX = exact[index * 3]; + double exactY = exact[index * 3 + 1]; + double exactZ = exact[index * 3 + 2]; + for (IrisObjectReplace j : config.getEdit()) { + if (rng.chance(j.getChance())) { + for (NativeBlockState k : j.getFind(rdata)) { + if (d == null) { + continue; + } + if (j.isExact() ? k.matches(d) : IrisObjectShaping.materialKey(k).equals(IrisObjectShaping.materialKey(d))) { + NativeBlockState newData = j.getReplace(rng, exactX + x, exactY + y, exactZ + z, rdata); + + if (IrisObjectShaping.materialKey(newData).equals(IrisObjectShaping.materialKey(d))) { + d = BlockDataMergeSupport.merge(d, newData); + } else { + d = newData; + } + } + } + } + } + } + + if (d == null || !palette.shouldStiltPlaced(d)) + continue; + + xx = x + bake.offsetX(index); + zz = z + bake.offsetZ(index); + + if (warped) { + double exactX = exact[index * 3]; + double exactY = exact[index * 3 + 1]; + double exactZ = exact[index * 3 + 2]; + xx += surfaceWarp.fitDouble(-warpHalf, warpHalf, exactX + x, exactY + y, exactZ + z); + zz += surfaceWarp.fitDouble(-warpHalf, warpHalf, exactZ + z, exactY + y, exactX + x); + } + + if (organic) { + int startY = targetLayer + y; + int maxScan = settings != null ? Math.max(1, settings.getOrganicMaxScan()) : 48; + int jitterMax = settings != null ? Math.max(0, settings.getOrganicJitter()) : 3; + double scratch = settings != null ? Math.max(0, Math.min(1, settings.getOrganicScratch())) : 0.55; + long colHash = ((long) xx * 341873128712L) ^ ((long) zz * 132897987541L); + int jitter = jitterMax > 0 ? (int) (Math.abs(colHash) % (jitterMax + 1)) : 0; + + if (ceilingHang) { + int scan = 0; + int solidY = startY + 1; + while (scan < maxScan && !placer.isSolid(xx, solidY, zz)) { + solidY++; + scan++; + } + int topBound = (scan < maxScan ? solidY - 1 : startY + Math.min(maxScan, 8)) - jitter; + int total = topBound - startY; + for (int j = startY; j <= topBound; j++) { + if (scratch > 0 && total > 0) { + double ratio = (double) (j - startY) / total; + if (ratio > (1.0 - scratch)) { + long sh = ((long) xx * 341873128712L) ^ ((long) j * 132897987541L) ^ ((long) zz * 735791245321L); + double skipChance = (ratio - (1.0 - scratch)) / scratch; + if ((Math.abs(sh) % 1000) / 1000.0 < skipChance * 0.7) { + continue; + } + } + } + placer.set(xx, j, zz, ObjectBlockPass.normalizePlacedWaterlogging(d, placer, xx, j, zz, waterlogCandidate)); + } + } else { + int scan = 0; + int solidY = startY - 1; + while (scan < maxScan && !placer.isSolid(xx, solidY, zz)) { + solidY--; + scan++; + } + int bottomBound = (scan < maxScan ? solidY + 1 : startY - Math.min(maxScan, 8)) + jitter; + int total = startY - bottomBound; + for (int j = startY; j >= bottomBound; j--) { + if (scratch > 0 && total > 0) { + double ratio = (double) (startY - j) / total; + if (ratio > (1.0 - scratch)) { + long sh = ((long) xx * 341873128712L) ^ ((long) j * 132897987541L) ^ ((long) zz * 735791245321L); + double skipChance = (ratio - (1.0 - scratch)) / scratch; + if ((Math.abs(sh) % 1000) / 1000.0 < skipChance * 0.7) { + continue; + } + } + } + placer.set(xx, j, zz, ObjectBlockPass.normalizePlacedWaterlogging(d, placer, xx, j, zz, waterlogCandidate)); + } + } + continue; + } + + int highest = placer.getHighest(xx, zz, self.getLoader(), true); + + int lowerBound = highest - 1; + if (settings != null) { + lowerBound -= settings.getOverStilt() - rng.i(0, settings.getYRand()); + if (settings.getYMax() != 0) + lowerBound -= Math.min(settings.getYMax() - (lowest + y - highest), 0); + } + + if (eroding) { + double dx = exact[index * 3] - erodeCentroidX; + double dz = exact[index * 3 + 2] - erodeCentroidZ; + double normalizedDist = Math.sqrt(dx * dx + dz * dz) / erodeMaxDist; + normalizedDist = Math.min(normalizedDist, 1.0); + int totalDepth = (lowest + y) - lowerBound; + int erodeDepth = (int) (totalDepth * Math.pow(1.0 - normalizedDist, 1.5)); + lowerBound = (lowest + y) - erodeDepth; + } + + for (int j = lowest + y; j > lowerBound; j--) { + NativeBlockState fluidState = placer.get(xx, j, zz); + if (B.isFluid(fluidState)) { + break; + } + if (eroding) { + int depth = (lowest + y) - j; + int totalDepth = (lowest + y) - lowerBound; + double depthRatio = totalDepth > 0 ? (double) depth / totalDepth : 0; + if (depthRatio > 0.4) { + long hash = ((long) (xx * 341873128712L) ^ ((long) j * 132897987541L) ^ ((long) zz * 735791245321L)); + double skipChance = (depthRatio - 0.4) / 0.6; + if ((Math.abs(hash) % 1000) / 1000.0 < skipChance * 0.7) { + continue; + } + } + } + + if (B.isVineBlock(d)) { + d = ObjectBlockPass.attachVineFaces(placer, d, xx, j, zz); + } + placer.set(xx, j, zz, ObjectBlockPass.normalizePlacedWaterlogging(d, placer, xx, j, zz, waterlogCandidate)); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleSurfaceSupport.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleSurfaceSupport.java new file mode 100644 index 000000000..704a1fe03 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleSurfaceSupport.java @@ -0,0 +1,79 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.cave.CarvingMode; + +import art.arcane.iris.spi.IrisLogging; +import java.util.concurrent.ConcurrentHashMap; + +/** Carving and surface support rules, including the bedrock diagnostic. */ +final class PlacementModuleSurfaceSupport implements PlacementModule { + static final PlacementLifecycleDependency CHECK = new PlacementLifecycleDependency("validate.surface"); + private static final long IMPLAUSIBLE_BEDROCK_WARN_THROTTLE_MS = 5000L; + private static final ConcurrentHashMap IMPLAUSIBLE_BEDROCK_WARNS = new ConcurrentHashMap<>(); + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + lifecycle.add(CHECK, PlacementStage.VALIDATE, (context, state, ignored) -> { + IrisObjectPlacement config = state.config; + if (context.yv < 0 && config.getCarvingSupport().supportsSurface() + && config.getMode() != ObjectPlaceMode.FLOATING && config.getMode() != ObjectPlaceMode.STRUCTURE_PIECE + && (state.paintProjection != null + ? IrisSurfaceSupport.intersectsHydrology(context.placer, context.x, context.z, null, null, 0, 0, 0, state.paintSupport) + : IrisSurfaceSupport.intersectsHydrology(context.placer, context.x, context.z, config.getTranslate(), + config.getRotation(), state.spinX, state.spinY, state.spinZ, context.self.getSurfaceSupportOffsets()))) { + state.reject("hydrology-support"); + return; + } + boolean rawStructurePiece = config.getMode() == ObjectPlaceMode.STRUCTURE_PIECE; + if (context.yv < 0 && !config.isForcePlace() && !config.isFromBottom() + && config.getMode() != ObjectPlaceMode.FLOATING && !rawStructurePiece + && config.getCarvingSupport().supportsSurface() && state.placer.getEngine() != null + && state.placer.getEngine().getDimension().isBedrock() && state.y <= 1) { + warnImplausibleBedrockPlacement(context.self, state.placer, config, context.x, state.y, context.z); + state.reject("bedrock-anchor"); + return; + } + boolean surfaceAnchored = context.yv < 0 ? config.getCarvingSupport().supportsSurface() + : config.getCarvingSupport() == CarvingMode.SURFACE_ONLY; + if (surfaceAnchored && !config.isForcePlace() && !config.isFromBottom() + && config.getMode() != ObjectPlaceMode.FLOATING && !rawStructurePiece + && !config.isUnderwater() && !config.isOnwater() && config.isRequireSurfaceSupport() + && (state.paintProjection != null + ? IrisSurfaceSupport.isUnsupported(context.placer, context.self.getLoader(), context.x, context.z, null, null, + 0, 0, 0, state.paintSupport, config.getSurfaceSupportBuffer(), config.getSurfaceSupportDepth(), state.paintProjection) + : IrisSurfaceSupport.isUnsupported(context.placer, context.self.getLoader(), context.x, context.z, + config.getTranslate(), config.getRotation(), state.spinX, state.spinY, state.spinZ, + context.self.getSurfaceSupportOffsets(), config.getSurfaceSupportBuffer(), config.getSurfaceSupportDepth()))) { + state.reject("surface-support"); + } + }, PlacementModulePaint.PROJECT); + } + + static boolean rejectsAnchor(PlacementContext context, PlacementState state, int x, int y, int z) { + return switch (state.config.getCarvingSupport()) { + case SURFACE_ONLY -> state.placer.isCarved(x, y, z); + case CARVING_ONLY -> !(state.placer.isCarved(x, y, z) || state.placer.isCarved(x, y - 1, z) + || state.placer.isCarved(x, y - 2, z) || state.placer.isCarved(x, y - 3, z)); + case ANYWHERE -> false; + }; + } + + private static void warnImplausibleBedrockPlacement(IrisObject self, IObjectPlacer placer, IrisObjectPlacement config, int x, int y, int z) { + String key = self.getLoadKey(); + String fingerprint = (key == null ? "" : key) + "|" + config.getMode(); + long now = System.currentTimeMillis(); + Long last = IMPLAUSIBLE_BEDROCK_WARNS.get(fingerprint); + if (last != null && now - last < IMPLAUSIBLE_BEDROCK_WARN_THROTTLE_MS) { + return; + } + IMPLAUSIBLE_BEDROCK_WARNS.put(fingerprint, now); + IrisLogging.warn("Implausible object placement rejected: " + + (key == null ? "" : key) + + " resolved anchorY=" + y + " at (" + x + "," + z + ") mode=" + config.getMode() + + " carving=" + config.getCarvingSupport() + + ". Surface-anchored placement should never land on the bedrock row. " + + "Height sampling returned a bogus value — not configured for floor placement " + + "(forcePlace=false, fromBottom=false, mode!=FLOATING). Skipping to protect bedrock."); + } + +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleTreeGrounding.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleTreeGrounding.java new file mode 100644 index 000000000..2c8480a25 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleTreeGrounding.java @@ -0,0 +1,57 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.decoration.tree.TreeGrounding; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.BlockPosition; + +/** Procedural trees extend their roots into supporting terrain instead of stilting or floating. */ +final class PlacementModuleTreeGrounding implements PlacementModule { + static final PlacementLifecycleDependency PLAN = new PlacementLifecycleDependency("grounding.plan"); + static final PlacementLifecycleDependency ROOTS = new PlacementLifecycleDependency("grounding.roots"); + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + lifecycle.add(PLAN, PlacementStage.VALIDATE, PlacementModuleTreeGrounding::plan, PlacementModuleAnchor.POSITIONED); + lifecycle.add(ROOTS, PlacementStage.AFTER_BLOCKS, PlacementModuleTreeGrounding::extendRoots, + PLAN, PlacementModuleBlocks.COMPLETE); + } + + private static void plan(PlacementContext context, PlacementState state, Void ignored) { + TreeGrounding grounding = context.self.getTreeGrounding(); + if (grounding == null || !isSurfaceTreePlacement(context, state)) { + return; + } + state.grounding = grounding.plan(state.placer, new TreeGrounding.Placement(context.rdata, state.config, + new BlockPosition(context.x, state.y + state.yrand, context.z), + new BlockPosition(state.spinX, state.spinY, state.spinZ))); + if (!state.grounding.supported()) { + state.reject("tree-grounding"); + } + } + + private static boolean isSurfaceTreePlacement(PlacementContext context, PlacementState state) { + IrisObjectPlacement config = state.config; + ObjectPlaceMode mode = config.getMode(); + return context.yv < 0 && config.getCarvingSupport().supportsSurface() + && !config.isForcePlace() && !config.isFromBottom() && !state.ceilingHang + && mode != ObjectPlaceMode.PAINT && mode != ObjectPlaceMode.FLOATING + && mode != ObjectPlaceMode.STRUCTURE_PIECE; + } + + private static void extendRoots(PlacementContext context, PlacementState state, Void ignored) { + if (state.grounding == null) { + return; + } + boolean waterlogCandidate = state.config.isWaterloggable() || state.config.isUnderwater(); + for (TreeGrounding.Extension extension : state.grounding.extensions()) { + for (int rootY = extension.topY(); rootY >= extension.bottomY(); rootY--) { + NativeBlockState root = ObjectBlockPass.normalizePlacedWaterlogging(extension.state(), state.placer, + extension.x(), rootY, extension.z(), waterlogCandidate); + state.placer.set(extension.x(), rootY, extension.z(), root); + if (context.listener != null) { + context.listener.accept(new BlockPosition(extension.x(), rootY, extension.z()), root); + } + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleVacuum.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleVacuum.java new file mode 100644 index 000000000..0172e7755 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementModuleVacuum.java @@ -0,0 +1,91 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.decoration.IrisVacuumSettings; + +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.iris.generation.block.B; +import art.arcane.volmlib.util.noise.SimplexNoise; + +/** Collects written object bounds, then bends terrain to meet them. Installed for vacuum modes. */ +final class PlacementModuleVacuum implements PlacementModule { + static final PlacementLifecycleDependency COLLECT = new PlacementLifecycleDependency("vacuum.collect"); + static final PlacementLifecycleDependency FINISH = new PlacementLifecycleDependency("vacuum.finish"); + private static final long VACUUM_WAVE_SEED = 7392113L; + + @Override + public void install(PlacementLifecycleBuilder lifecycle) { + lifecycle.add(COLLECT, PlacementStage.BLOCK_WRITTEN, (context, state, block) -> { + state.vacuumLowest = Math.min(state.vacuumLowest, block.y()); + state.vacuumHighest = Math.max(state.vacuumHighest, block.y()); + }); + lifecycle.add(FINISH, PlacementStage.AFTER_BLOCKS, (context, state, ignored) -> { + if (state.vacuumLowest == Integer.MAX_VALUE || state.placer.getEngine() == null) { + return; + } + vacuumTerrain(context.self, state.placer, state.config, context.x, context.z, + state.bounds.minX(), state.bounds.maxX(), state.bounds.minZ(), state.bounds.maxZ(), + state.vacuumLowest, state.vacuumHighest); + }, COLLECT, PlacementModuleBlocks.COMPLETE, PlacementModuleTreeGrounding.ROOTS); + } + + private static void vacuumTerrain(IrisObject self, IObjectPlacer placer, IrisObjectPlacement config, int centerX, int centerZ, int lowX, int highX, int lowZ, int highZ, int baseY, int topY) { + ObjectPlaceMode mode = config.getMode(); + IrisVacuumSettings settings = config.getVacuumSettings(); + int radius = IrisObjectVacuum.resolveRadius(mode, settings); + int step = IrisObjectVacuum.resolveStep(mode); + double falloff = IrisObjectVacuum.resolveFalloff(settings); + int jitter = settings != null ? Math.max(0, settings.getOrganicJitter()) : 4; + boolean organicEdge = mode == ObjectPlaceMode.VACUUM_ORGANIC; + boolean wavyEdge = mode == ObjectPlaceMode.VACUUM_WAVY; + double waveAmplitude = IrisObjectVacuum.resolveWaveAmplitude(settings); + double waveScale = IrisObjectVacuum.resolveWaveScale(settings); + SimplexNoise waveNoise = (wavyEdge && waveAmplitude > 0) ? new SimplexNoise(VACUUM_WAVE_SEED) : null; + int meetY = baseY - 1; + + IrisComplex complex = placer.getEngine().getComplex(); + int worldMin = placer.getEngine().getMinHeight(); + int worldMax = worldMin + placer.getEngine().getHeight() - 1; + + for (int dx = lowX - radius; dx <= highX + radius; dx += step) { + for (int dz = lowZ - radius; dz <= highZ + radius; dz += step) { + int cx = centerX + dx; + int cz = centerZ + dz; + double effRadius = radius; + if (organicEdge && jitter > 0) { + long h = ((long) cx * 341873128712L) ^ ((long) cz * 132897987541L); + double n = ((Math.abs(h) % 1000) / 1000.0) - 0.5; + effRadius = Math.max(1.0, radius + (n * 2.0 * jitter)); + } + int origY = placer.getHighest(cx, cz, self.getLoader(), true); + int targetY = IrisObjectVacuum.columnTargetY(dx, dz, lowX, highX, lowZ, highZ, effRadius, falloff, origY, meetY); + if (waveNoise != null) { + int outX = IrisObjectVacuum.outset(dx, lowX, highX); + int outZ = IrisObjectVacuum.outset(dz, lowZ, highZ); + double waveDistance = Math.sqrt((double) (outX * outX) + (double) (outZ * outZ)); + double sample = waveNoise.noiseSigned(cx * waveScale, cz * waveScale); + targetY += IrisObjectVacuum.waveOffset(waveDistance, effRadius, sample, waveAmplitude); + } + if (targetY == origY) { + continue; + } + targetY = Math.max(worldMin + 1, Math.min(worldMax, targetY)); + if (targetY > origY) { + NativeBlockState fill = complex != null ? complex.getRockStream().get(cx, cz) : null; + if (B.isAir(fill)) { + fill = IrisObject.States.stone(); + } + for (int yy = origY + 1; yy <= targetY; yy++) { + placer.set(cx, yy, cz, fill); + } + } else if (targetY < origY) { + boolean inside = IrisObjectVacuum.outset(dx, lowX, highX) == 0 && IrisObjectVacuum.outset(dz, lowZ, highZ) == 0; + int carveFloor = IrisObjectVacuum.carveFloorY(targetY, topY, inside); + for (int yy = origY; yy >= carveFloor; yy--) { + placer.set(cx, yy, cz, IrisObject.States.air()); + } + } + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementPalette.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementPalette.java new file mode 100644 index 000000000..6328050e3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementPalette.java @@ -0,0 +1,164 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.decoration.IrisProceduralBlocks; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; + +import java.util.IdentityHashMap; +import java.util.Set; + +/** Resolves decay, rotation, waterlog, air and stilt facts once per baked palette entry for one placement. */ +final class PlacementPalette { + record Facts(NativeBlockState rotated, boolean waterloggable, boolean authoredWaterlogged, + NativeBlockState dry, boolean air) { + private static final Facts ROTATED_AWAY = new Facts(null, false, false, null, false); + } + + private static final Object ROTATED_AWAY = new Object(); + private static final byte UNKNOWN = 0; + private static final byte NO = 1; + private static final byte YES = 2; + + private final NativeBlockState[] palette; + private final IrisObjectRotation rotation; + private final int spinX; + private final int spinY; + private final int spinZ; + private final boolean preventingDecay; + private final boolean debugSmartBore; + private final Set excludedStiltMaterials; + private final NativeBlockState[] decayed; + private final Facts[] facts; + private final byte[] stiltLayers; + private final byte[] stilts; + private final NativeBlockState[] stiltDefaults; + private IdentityHashMap rotations; + private IdentityHashMap editedFacts; + private IdentityHashMap placedStilts; + + PlacementPalette(NativeBlockState[] palette, IrisObjectRotation rotation, int spinX, int spinY, int spinZ, + boolean preventingDecay, boolean debugSmartBore, Set excludedStiltMaterials) { + this.palette = palette; + this.rotation = rotation; + this.spinX = spinX; + this.spinY = spinY; + this.spinZ = spinZ; + this.preventingDecay = preventingDecay; + this.debugSmartBore = debugSmartBore; + this.excludedStiltMaterials = excludedStiltMaterials; + this.decayed = new NativeBlockState[palette.length]; + this.facts = new Facts[palette.length]; + this.stiltLayers = new byte[palette.length]; + this.stilts = new byte[palette.length]; + this.stiltDefaults = new NativeBlockState[palette.length]; + } + + private NativeBlockState source(int entry) { + NativeBlockState state = palette[entry]; + return debugSmartBore && IrisObject.States.vair().equals(state) ? IrisObject.States.vairDebug() : state; + } + + boolean stiltLayer(int entry) { + byte known = stiltLayers[entry]; + if (known == UNKNOWN) { + known = IrisObjectShaping.isStiltLayerBlock(source(entry)) ? YES : NO; + stiltLayers[entry] = known; + } + return known == YES; + } + + NativeBlockState decayed(int entry) { + NativeBlockState resolved = decayed[entry]; + if (resolved == null) { + NativeBlockState data = source(entry); + resolved = preventingDecay && IrisProceduralBlocks.hasProperty(data, "distance") + && "false".equals(IrisProceduralBlocks.propertyValue(data, "persistent")) + ? data.withProperty("persistent", "true") : data; + decayed[entry] = resolved; + } + return resolved; + } + + Facts facts(int entry) { + Facts resolved = facts[entry]; + if (resolved == null) { + resolved = computeFacts(decayed(entry)); + facts[entry] = resolved; + } + return resolved; + } + + Facts factsOf(NativeBlockState state) { + if (editedFacts == null) { + editedFacts = new IdentityHashMap<>(); + } + Facts resolved = editedFacts.get(state); + if (resolved == null) { + resolved = computeFacts(state); + editedFacts.put(state, resolved); + } + return resolved; + } + + private Facts computeFacts(NativeBlockState state) { + NativeBlockState rotated = rotate(state); + if (rotated == null) { + return Facts.ROTATED_AWAY; + } + boolean waterloggable = IrisProceduralBlocks.hasProperty(rotated, "waterlogged"); + boolean authored = waterloggable && "true".equals(IrisProceduralBlocks.propertyValue(rotated, "waterlogged")); + NativeBlockState dry = waterloggable ? IrisProceduralBlocks.normalizeWaterlogging(rotated, null, false) : rotated; + String material = IrisObjectShaping.materialKey(rotated); + boolean air = material.equals("minecraft:air") || material.equals("minecraft:cave_air"); + return new Facts(rotated, waterloggable, authored, dry, air); + } + + NativeBlockState rotate(NativeBlockState state) { + if (state == null) { + return null; + } + if (rotations == null) { + rotations = new IdentityHashMap<>(); + } + Object memo = rotations.get(state); + if (memo == null) { + NativeBlockState rotated = rotation.rotate(state, spinX, spinY, spinZ); + memo = rotated == null ? ROTATED_AWAY : rotated; + rotations.put(state, memo); + } + return memo == ROTATED_AWAY ? null : (NativeBlockState) memo; + } + + boolean shouldStilt(int entry) { + byte known = stilts[entry]; + if (known == UNKNOWN) { + known = IrisObjectShaping.shouldStilt(palette[entry], excludedStiltMaterials) ? YES : NO; + stilts[entry] = known; + } + return known == YES; + } + + NativeBlockState stiltDefault(int entry) { + NativeBlockState resolved = stiltDefaults[entry]; + if (resolved == null) { + NativeBlockState state = palette[entry]; + String material = IrisObjectShaping.materialKey(state); + resolved = material.equals("minecraft:grass_block") || material.equals("minecraft:mycelium") + || material.equals("minecraft:podzol") || material.equals("minecraft:dirt_path") + ? IrisObject.States.dirt() : state; + stiltDefaults[entry] = resolved; + } + return resolved; + } + + boolean shouldStiltPlaced(NativeBlockState state) { + if (placedStilts == null) { + placedStilts = new IdentityHashMap<>(); + } + Boolean known = placedStilts.get(state); + if (known == null) { + known = IrisObjectShaping.shouldStilt(state, excludedStiltMaterials); + placedStilts.put(state, known); + } + return known; + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementStage.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementStage.java new file mode 100644 index 000000000..94e0c6e1a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementStage.java @@ -0,0 +1,37 @@ +package art.arcane.iris.structure.object; + + +import java.util.List; + +/** + * Whole-placement phases surround a nested block pass. Block transforms are baked per placement, + * so the pass exposes only BLOCK_WRITTEN, which runs for successful object writes. AFTER_BLOCKS + * waits for the entire block pass, including markers. All whole-placement validation precedes + * world writes. + */ +public final class PlacementStage { + public static final PlacementStage PREPARE = new PlacementStage<>("prepare", 0); + public static final PlacementStage HEIGHT = new PlacementStage<>("height", 1); + public static final PlacementStage VALIDATE = new PlacementStage<>("validate", 2); + public static final PlacementStage BEFORE_BLOCKS = new PlacementStage<>("before-blocks", 3); + public static final PlacementStage BLOCK_WRITTEN = new PlacementStage<>("block-written", 4); + // BLOCKS denotes completion of the whole pass, so later steps can depend on all block work. + public static final PlacementStage BLOCKS = new PlacementStage<>("blocks", 5); + public static final PlacementStage AFTER_BLOCKS = new PlacementStage<>("after-blocks", 6); + static final List> ALL = List.of(PREPARE, HEIGHT, VALIDATE, BEFORE_BLOCKS, + BLOCK_WRITTEN, BLOCKS, AFTER_BLOCKS); + static final List> BEFORE_PASS = List.of(PREPARE, HEIGHT, VALIDATE, BEFORE_BLOCKS); + + private final String name; + final int order; + + private PlacementStage(String name, int order) { + this.name = name; + this.order = order; + } + + @Override + public String toString() { + return name; + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/PlacementState.java b/core/src/main/java/art/arcane/iris/structure/object/PlacementState.java new file mode 100644 index 000000000..0530b1e12 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/PlacementState.java @@ -0,0 +1,72 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.decoration.tree.TreeGrounding; +import art.arcane.iris.structure.object.PlacementContext.TransformedBounds; +import art.arcane.iris.structure.object.PlacementContext.WorldBounds; +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.math.Position2; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; + +import java.util.Objects; + +/** Results and accumulators owned by one placement, never by a shared module or plan. */ +@Getter +@Setter +public final class PlacementState { + IrisObjectPlacement config; + IObjectPlacer placer; + SpinKernel spin; + int spinX; + int spinY; + int spinZ; + boolean ceilingHang; + IrisBlockVector translateOffset; + int rty; + int ty; + int yrand; + int warpMargin; + int y; + TransformedBounds bounds; + WorldBounds worldBounds; + IrisPaintSurfaceProjection paintProjection; + art.arcane.volmlib.util.collection.KList paintSupport; + TreeGrounding.Plan grounding; + ObjectPlacementBake bake; + PlacementPalette palette; + int vacuumLowest = Integer.MAX_VALUE; + int vacuumHighest = Integer.MIN_VALUE; + KMap snowHeights; + + @Setter(AccessLevel.NONE) + private String rejection; + + PlacementState(PlacementContext context) { + this(-1, null); + config = context.config; + placer = context.placer; + } + + PlacementState(int y, TransformedBounds bounds) { + this.y = y; + this.bounds = bounds; + } + + /** The first rejection is terminal. Each guard decides whether forcePlace exempts its rule. */ + public void reject(String reason) { + Objects.requireNonNull(reason, "Rejection reason"); + if (rejection == null) { + rejection = reason; + } + } + + public boolean isRejected() { + return rejection != null; + } + + public int heightOrRejected() { + return isRejected() ? -1 : y; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/EnginePlacementLifecycleTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/EnginePlacementLifecycleTest.java new file mode 100644 index 000000000..e821d14d6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/EnginePlacementLifecycleTest.java @@ -0,0 +1,62 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.runtime.EngineEffects; +import art.arcane.iris.generation.runtime.EngineMode; +import art.arcane.iris.generation.runtime.EngineTarget; +import art.arcane.iris.generation.runtime.EngineWorldManager; +import art.arcane.iris.structure.object.ObjectPlaceMode; +import org.junit.Test; + +import java.util.concurrent.CompletableFuture; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; + +public class EnginePlacementLifecycleTest { + @Test + public void runtimeAssemblyResolvesBeforePublicationAndKeepsOldPlansIntact() { + var initial = assembly(1); + EngineRuntime previous = initial.freezeRuntime(new GenerationRuntime.BiomeMaxes(0, 0, 0)); + var replacement = assembly(2); + EngineRuntime next = replacement.freezeRuntime(new GenerationRuntime.BiomeMaxes(0, 0, 0)); + + assertSame(initial.placementLifecycles, previous.generation().placementLifecycles()); + assertSame(replacement.placementLifecycles, next.generation().placementLifecycles()); + for (ObjectPlaceMode mode : ObjectPlaceMode.values()) { + assertNotNull(next.generation().placementLifecycles().get(mode)); + assertNotSame(previous.generation().placementLifecycles().get(mode), next.generation().placementLifecycles().get(mode)); + } + assertSame(initial.placementLifecycles, previous.generation().placementLifecycles()); + } + + @Test + public void complexReplacementCarriesNewPlansAlongsideTheNewComplex() { + var initial = assembly(1); + EngineRuntime previous = initial.freezeRuntime(new GenerationRuntime.BiomeMaxes(0, 0, 0)); + var replacement = assembly(2); + EngineRuntime next = previous.withGeneration(previous.generation().withComplex(2, replacement.complex, null, null, + new GenerationRuntime.BiomeMaxes(1, 1, 1), replacement.placementLifecycles)); + + assertSame(replacement.placementLifecycles, next.generation().placementLifecycles()); + assertSame(replacement.complex, next.generation().complex()); + assertSame(previous.worldManager(), next.worldManager()); + assertSame(initial.placementLifecycles, previous.generation().placementLifecycles()); + } + + private EngineRuntimeBuilder.RuntimeAssembly assembly(int id) { + EngineTarget target = mock(EngineTarget.class); + var world = mock(art.arcane.iris.world.IrisWorld.class); + org.mockito.Mockito.when(target.getWorld()).thenReturn(world); + org.mockito.Mockito.when(target.getData()).thenReturn(mock(art.arcane.iris.pack.loading.IrisData.class)); + org.mockito.Mockito.when(target.getDimension()).thenReturn(mock(art.arcane.iris.generation.terrain.IrisDimension.class)); + var assembly = new EngineRuntimeBuilder.RuntimeAssembly(id, target, + java.nio.file.Path.of("build", "test-mantles", "placement-" + id)); + assembly.mantle = mock(art.arcane.iris.generation.mantle.EngineMantle.class); + assembly.complex = mock(IrisComplex.class); + assembly.effects = mock(EngineEffects.class); + assembly.mode = mock(EngineMode.class); + assembly.worldManager = mock(EngineWorldManager.class); + assembly.hash32 = CompletableFuture.completedFuture(0L); + return assembly; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java index 1704b654a..f880e0de3 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java @@ -858,7 +858,7 @@ public class EngineShutdownDrainTest { when(generation.runtimeKernel()).thenReturn(kernel); GenerationRuntime nextGeneration = generation(target, mantle); when(nextGeneration.complex()).thenReturn(replacement); - when(generation.withComplex(anyInt(), same(replacement), isNull(), isNull(), any())) + when(generation.withComplex(anyInt(), same(replacement), isNull(), isNull(), any(), any())) .thenReturn(nextGeneration); EngineRuntimeBuilder builder = spy(new EngineRuntimeBuilder(engine)); doReturn(new GenerationRuntime.BiomeMaxes(0D, 0D, 0D)).when(builder).computeBiomeMaxes(); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java index ac7a33074..4a3df8846 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java @@ -335,6 +335,7 @@ public class IrisEngineGenerationRuntimeScopeTest { assertSame(detached.dimension, engine.getDimension()); assertSame(detached.runtime.seedManager(), engine.getSeedManager()); assertSame(detached.complex, engine.getComplex()); + assertSame(detached.runtime.placementLifecycles(), engine.getPlacementLifecycles()); assertSame(detached.upperContext, engine.getUpperContext()); assertSame(detached.dimensionStackContext, engine.getDimensionStackContext()); assertSame(detached.mode, engine.getMode()); @@ -351,6 +352,7 @@ public class IrisEngineGenerationRuntimeScopeTest { assertSame(active.target, engine.getTarget()); assertSame(active.data, engine.getData()); assertSame(active.runtime.seedManager(), engine.getSeedManager()); + assertSame(active.runtime.placementLifecycles(), engine.getPlacementLifecycles()); assertSame(active.mantle, engine.getMantle()); assertEquals(1, engine.getCacheID()); } @@ -1009,7 +1011,8 @@ public class IrisEngineGenerationRuntimeScopeTest { mantle, mantleStorageDirectory, hash32, - new BiomeMaxes(objectDensity, layerDensity, decoratorDensity)); + new BiomeMaxes(objectDensity, layerDensity, decoratorDensity), + art.arcane.iris.structure.object.PlacementLifecycles.resolve()); return new RuntimeFixture( runtime, target, diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementLifecycleRegressionTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementLifecycleRegressionTest.java new file mode 100644 index 000000000..80e3019bc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementLifecycleRegressionTest.java @@ -0,0 +1,617 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.terrain.IrisSlopeClip; + +import art.arcane.iris.generation.noise.IrisNoiseGenerator; + +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.cave.CarveResult; +import art.arcane.iris.generation.cave.CarvingMode; +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.testsupport.PlatformLeakGuard; +import org.junit.ClassRule; + + +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; +import art.arcane.iris.structure.placement.PlacedObject; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.stream.ProceduralStream; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisObjectPlacementLifecycleRegressionTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + private static final int ANCHOR_Y = 80; + + private IrisData data; + private Engine engine; + private NativeBlockState solid; + private NativeBlockState schematicAir; + + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformRegistries registries = mock(PlatformRegistries.class); + Map registryStates = new HashMap<>(); + when(registries.block(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0); + return registryStates.computeIfAbsent(key, value -> state(value.toLowerCase(), !value.toLowerCase().contains("air"))); + }); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + + engine = mock(Engine.class); + when(engine.getHeight()).thenReturn(256); + data = mock(IrisData.class); + when(data.getEngine()).thenReturn(engine); + solid = state("minecraft:stone", true); + schematicAir = state("minecraft:air", false); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void placementFailurePropagatesToTheCaller() { + RecordingPlacer placer = new RecordingPlacer(null); + placer.failAfterWrites(1); + IrisObject object = lineObject(3); + + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> object.place(0, ANCHOR_Y, 0, placer, placement(), new RNG(2L), data)); + + assertTrue(error.getMessage().contains("write failed")); + assertEquals(1, placer.writes().size()); + } + + @Test + public void boreUsesRotatedTranslationAndRandomizedAnchor() { + RecordingPlacer placer = new RecordingPlacer(null); + IrisObjectPlacement placement = placement(); + placement.setBore(true); + placement.setRotation(IrisObjectRotation.of(0, 90, 0)); + placement.setTranslate(new IrisObjectTranslate().setX(4).setYRandom(3)); + + int resultY = lineObject(3).place(0, ANCHOR_Y, 0, placer, placement, new RNG(2L), data); + + List airWrites = placer.writesOf(IrisObject.States.air()); + assertFalse(airWrites.isEmpty()); + assertTrue(airWrites.stream().allMatch(write -> write.y() >= resultY && write.y() <= resultY)); + assertTrue(airWrites.stream().allMatch(write -> write.x() == 0)); + assertTrue(airWrites.stream().allMatch(write -> write.z() <= -3 && write.z() >= -5)); + } + + @Test + public void collisionCheckUsesTheTransformedBounds() { + PlacedObject forbidden = new PlacedObject(null, object("forbidden"), 1, 0, 0); + when(engine.resolveObjectPlacementMarker(anyInt(), anyInt(), org.mockito.ArgumentMatchers.isNull())).thenAnswer(invocation -> { + int x = invocation.getArgument(0); + int z = invocation.getArgument(1); + return x == 0 && z == -4 ? forbidden : null; + }); + RecordingPlacer placer = new RecordingPlacer(engine); + IrisObjectPlacement placement = placement(); + placement.setRotation(IrisObjectRotation.of(0, 90, 0)); + placement.setTranslate(new IrisObjectTranslate().setX(4)); + placement.getForbiddenCollisions().add("forbidden"); + + int result = lineObject(3).place(0, ANCHOR_Y, 0, placer, placement, new RNG(2L), data); + + assertEquals(-1, result); + assertTrue(placer.writes().isEmpty()); + } + + @Test + public void arbitraryRotationSamplesEveryTransformedFootprintColumn() { + RecordingPlacer placer = new RecordingPlacer(null); + IrisObjectPlacement placement = placement(); + placement.setMode(ObjectPlaceMode.MAX_HEIGHT); + placement.setRotation(IrisObjectRotation.of(0, 45, 0)); + + boxObject(5, 1, 3).place(0, -1, 0, placer, placement, new RNG(2L), data); + + assertTrue(placer.sampledColumns().contains("-2:-2")); + assertTrue(placer.sampledColumns().contains("2:2")); + assertTrue(placer.sampledColumns().size() >= 25); + } + + @Test + public void snowUsesOnlyBlocksThatWereWritten() { + RecordingPlacer placer = new RecordingPlacer(null); + IrisObject object = new IrisObject(1, 3, 1); + object.setUnsigned(0, 0, 0, solid); + object.setUnsigned(0, 2, 0, schematicAir); + IrisObjectPlacement placement = placement(); + placement.setSnow(0.1); + + object.place(0, ANCHOR_Y, 0, placer, placement, new RNG(2L), data); + + List snowWrites = placer.writesOf(IrisObject.States.snowLayer(0)); + assertEquals(1, snowWrites.size()); + assertEquals(ANCHOR_Y, snowWrites.get(0).y()); + } + + @Test + public void debugPlacementDoesNotMutateSmartBoreCache() { + IrisObject object = new IrisObject(1, 1, 1); + object.setUnsigned(0, 0, 0, IrisObject.States.vair()); + object.setSmartBored(true); + IrisObjectPlacement placement = placement(); + placement.setSmartBore(true); + RecordingPlacer debug = new RecordingPlacer(null); + debug.setDebugSmartBore(true); + RecordingPlacer normal = new RecordingPlacer(null); + + object.place(0, ANCHOR_Y, 0, debug, placement, new RNG(2L), data); + object.place(0, ANCHOR_Y, 0, normal, placement, new RNG(2L), data); + + NativeBlockState cachedState = object.getBlocks().get(object.getSigned(0, 0, 0)); + assertSame(IrisObject.States.vair(), cachedState); + if (IrisObject.States.vair() != IrisObject.States.vairDebug()) { + assertTrue(cachedState != IrisObject.States.vairDebug()); + } + } + + @Test + public void placementDataIsRequiredAtTheBoundary() { + NullPointerException error = assertThrows(NullPointerException.class, + () -> lineObject(1).place(0, ANCHOR_Y, 0, new RecordingPlacer(null), placement(), new RNG(2L), null)); + + assertEquals("Object placement data is required.", error.getMessage()); + } + + private IrisObjectPlacement placement() { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setMode(ObjectPlaceMode.CENTER_HEIGHT); + placement.setRequireSurfaceSupport(false); + return placement; + } + + @Test + public void stiltWaitsForTheWholeTransformedFootprint() { + RecordingPlacer placer = new RecordingPlacer(null); + IrisObjectPlacement config = placement(); + config.setMode(ObjectPlaceMode.STILT); + config.setRotation(IrisObjectRotation.of(0, 90, 0)); + config.setTranslate(new IrisObjectTranslate().setX(4)); + + lineObject(3).place(0, ANCHOR_Y + 4, 0, placer, config, new RNG(2L), data); + + // Every bottom column reaches the ground after rotation and translation. + for (int z = -5; z <= -3; z++) { + assertSame(solid, placer.get(0, ANCHOR_Y, z)); + assertSame(solid, placer.get(0, ANCHOR_Y + 3, z)); + } + assertTrue(placer.writes().stream().allMatch(write -> write.x() == 0)); + } + + @Test + public void ceilingFlipsBeforeTranslationAndFillsUpward() { + RecordingPlacer placer = new RecordingPlacer(null); + placer.world.put("0:90:0", solid); + IrisObjectPlacement config = placement(); + config.setMode(ObjectPlaceMode.CEILING_HANG); + config.setTranslate(new IrisObjectTranslate().setY(3)); + config.setStiltSettings(new IrisStiltSettings().setOrganicJitter(0).setOrganicScratch(0)); + + boxObject(1, 3, 1).place(0, ANCHOR_Y, 0, placer, config, new RNG(2L), data); + + assertSame(solid, placer.get(0, 82, 0)); + assertSame(solid, placer.get(0, 89, 0)); + assertTrue(placer.writes().stream().noneMatch(write -> write.y() < 82 || write.y() >= 90)); + } + + @Test + public void organicStiltStopsAtTheFloor() { + RecordingPlacer placer = new RecordingPlacer(null); + placer.world.put("0:75:0", solid); + IrisObjectPlacement config = placement(); + config.setMode(ObjectPlaceMode.ORGANIC_STILT); + config.setStiltSettings(new IrisStiltSettings().setOrganicJitter(0).setOrganicScratch(0)); + + lineObject(1).place(0, ANCHOR_Y, 0, placer, config, new RNG(2L), data); + + for (int y = 76; y <= ANCHOR_Y; y++) { + assertSame(solid, placer.get(0, y, 0)); + } + assertTrue(placer.writes().stream().noneMatch(write -> write.y() <= 75)); + } + + @Test + public void explicitMinimumHeightAndFloatingDoNotNeedTerrainSampling() { + for (ObjectPlaceMode mode : List.of(ObjectPlaceMode.MIN_HEIGHT, ObjectPlaceMode.FAST_MIN_HEIGHT, + ObjectPlaceMode.FLOATING, ObjectPlaceMode.STRUCTURE_PIECE)) { + RecordingPlacer placer = new RecordingPlacer(null); + IrisObjectPlacement config = placement(); + config.setMode(mode); + assertEquals(ANCHOR_Y, lineObject(1).place(0, ANCHOR_Y, 0, placer, config, new RNG(2L), data)); + assertTrue(mode.toString(), placer.sampledColumns().isEmpty()); + } + } + + @Test + public void runnerUsesTheEnginePlanAndCompletesTheBlockPassBeforeAfterBlocks() { + RecordingPlacer placer = new RecordingPlacer(engine); + PlacementLifecycleBuilder builder = new PlacementLifecycleBuilder(); + ObjectPlaceMode.CENTER_HEIGHT.install(builder); + builder.add(new PlacementLifecycleDependency("test.offset"), PlacementStage.HEIGHT, + (context, state, ignored) -> state.y += 5, PlacementModuleAnchor.HEIGHT); + List finishedSizes = new ArrayList<>(); + builder.add(new PlacementLifecycleDependency("test.finished"), PlacementStage.AFTER_BLOCKS, + (context, state, ignored) -> finishedSizes.add(placer.writes().size())); + PlacementLifecycles plans = mock(PlacementLifecycles.class); + when(plans.get(ObjectPlaceMode.CENTER_HEIGHT)).thenReturn(builder.build()); + when(engine.getPlacementLifecycles()).thenReturn(plans); + + assertEquals(ANCHOR_Y + 5, lineObject(3).place(0, ANCHOR_Y, 0, placer, placement(), new RNG(2L), data)); + assertEquals(List.of(3), finishedSizes); + assertTrue(placer.writes().stream().allMatch(write -> write.y() == ANCHOR_Y + 5)); + } + + @Test + public void fromBottomKeepsItsAnchorWithoutSamplingTerrain() { + RecordingPlacer placer = new RecordingPlacer(null); + IrisObjectPlacement config = placement(); + config.setFromBottom(true); + assertEquals(2, lineObject(1).place(0, -1, 0, placer, config, new RNG(2L), data)); + assertTrue(placer.sampledColumns().isEmpty()); + } + + @Test + public void eligibilityGuardsRejectBeforeBoreAndOwnTheirForcePlaceExemptions() { + for (String reason : List.of("carving-anchor", "underwater-anchor", "cave-clearance", "fluid-height", "height-clamp")) { + RecordingPlacer placer = new RecordingPlacer(null); + IrisObjectPlacement config = placement().setBore(true).setSnow(0.1); + CarveResult cave = null; + int anchor = ANCHOR_Y; + switch (reason) { + case "carving-anchor" -> { + placer.carved = true; + config.setCarvingSupport(CarvingMode.SURFACE_ONLY); + } + case "underwater-anchor" -> { + placer.underwater = true; + anchor = -1; + } + case "cave-clearance" -> cave = new CarveResult(ANCHOR_Y, ANCHOR_Y + 1); + case "fluid-height" -> { + config.setUnderwater(true); + placer.fluidHeight = ANCHOR_Y; + } + case "height-clamp" -> config.getClamp().setMaximumHeight(ANCHOR_Y - 1); + } + PlacementState result = execute(config, anchor, placer, cave); + assertEquals(reason, result.getRejection()); + assertEquals(-1, result.heightOrRejected()); + assertTrue(reason, placer.writes().isEmpty()); + + config.setForcePlace(true); + assertFalse(reason, execute(config, anchor, placer, cave).isRejected()); + assertFalse(reason, placer.writes().isEmpty()); + } + } + + @Test + public void forcePlaceCannotBypassNativeProtectionButStructurePiecesCan() { + when(engine.getNativeStructureVolumes(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn( + new KList<>(NativeStructureVolume.of("minecraft:village", 0, ANCHOR_Y, 0, 0, ANCHOR_Y, 0))); + RecordingPlacer placer = new RecordingPlacer(engine); + IrisObjectPlacement config = placement().setForcePlace(true).setBore(true).setSnow(0.1); + + assertEquals("native-structure", execute(config, ANCHOR_Y, placer, null).getRejection()); + assertTrue(placer.writes().isEmpty()); + + config.setMode(ObjectPlaceMode.STRUCTURE_PIECE); + assertEquals(ANCHOR_Y, execute(config, ANCHOR_Y, placer, null).heightOrRejected()); + assertFalse(placer.writes().isEmpty()); + } + + @Test + public void collisionRejectionPrecedesBoreAndAllowedObjectsOverrideForbiddenOnes() { + PlacedObject forbidden = new PlacedObject(null, object("forbidden"), 1, 0, 0); + when(engine.resolveObjectPlacementMarker(anyInt(), anyInt(), org.mockito.ArgumentMatchers.isNull())).thenReturn(forbidden); + RecordingPlacer placer = new RecordingPlacer(engine); + IrisObjectPlacement config = placement().setBore(true); + config.getForbiddenCollisions().add("forbidden"); + + assertEquals("object-collision", execute(config, ANCHOR_Y, placer, null).getRejection()); + assertTrue(placer.writes().isEmpty()); + + config.getAllowedCollisions().add("forbidden"); + assertFalse(execute(config, ANCHOR_Y, placer, null).isRejected()); + assertFalse(placer.writes().isEmpty()); + + config.getAllowedCollisions().clear(); + config.setForcePlace(true); + assertFalse(execute(config, ANCHOR_Y, new RecordingPlacer(engine), null).isRejected()); + } + + @Test + public void missingCollisionEngineFailsBeforeWrites() { + when(data.getEngine()).thenReturn(null); + RecordingPlacer placer = new RecordingPlacer(null); + IrisObjectPlacement config = placement().setBore(true); + config.getForbiddenCollisions().add("forbidden"); + assertEquals("Object placement requires active Iris data for collision settings.", + assertThrows(IllegalStateException.class, + () -> execute(config, ANCHOR_Y, placer, null)).getMessage()); + assertTrue(placer.writes().isEmpty()); + } + + @Test + public void vacuumAndSnowObserveOnlyWrittenBlocksAndFinishInOrder() { + RecordingPlacer placer = new RecordingPlacer(engine); + IrisObject object = new IrisObject(1, 3, 1); + object.setUnsigned(0, 0, 0, solid); + object.setUnsigned(0, 2, 0, schematicAir); + IrisObjectPlacement config = placement().setMode(ObjectPlaceMode.VACUUM).setSnow(0.1); + config.setTranslate(new IrisObjectTranslate().setY(3)); + config.setVacuumSettings(new IrisVacuumSettings().setRadius(1)); + + object.place(0, ANCHOR_Y, 0, placer, config, new RNG(2L), data); + + List center = placer.writes().stream().filter(write -> write.x() == 0 && write.z() == 0).toList(); + assertEquals(List.of(82, 81, 83), center.stream().map(BlockWrite::y).toList()); + assertSame(solid, center.get(0).state()); + assertSame(IrisObject.States.snowLayer(0), center.get(2).state()); + } + + @Test + public void preparationPreservesRandomDrawsForPlacementAndLaterCallers() { + for (int range : List.of(-3, 0, 3)) { + RNG rng = new RNG(91); + RNG expected = new RNG(91); + expected.imax(); + expected.imax(); + expected.imax(); + int offset = range > 0 ? expected.i(0, range) : range < 0 ? expected.i(range, 0) : 0; + IrisObjectPlacement config = placement().setBore(true); + config.setTranslate(new IrisObjectTranslate().setYRandom(range)); + assertEquals(ANCHOR_Y + offset, + lineObject(1).place(0, ANCHOR_Y, 0, new RecordingPlacer(null), config, rng, data)); + assertEquals(expected.nextLong(), rng.nextLong()); + } + } + + @Test + public void heightmapPreparationKeepsItsSeedDrawBeforeRotation() { + RNG rng = new RNG(91); + RNG expected = new RNG(91); + expected.nextLong(); + expected.imax(); + expected.imax(); + expected.imax(); + RecordingPlacer placer = new RecordingPlacer(null); + IrisObjectPlacement config = placement(); + config.setHeightmap(new IrisNoiseGenerator(false).setOffsetY(93)); + + assertEquals(93, lineObject(1).place(0, -1, 0, placer, config, rng, data)); + assertTrue(placer.sampledColumns().isEmpty()); + assertEquals(expected.nextLong(), rng.nextLong()); + } + + @Test + @SuppressWarnings("unchecked") + public void slopeRejectionConsumesNoTransformRandomness() { + IrisComplex complex = mock(IrisComplex.class); + ProceduralStream slopes = mock(ProceduralStream.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.getSlopeStream()).thenReturn(slopes); + when(slopes.get(anyDouble(), anyDouble())).thenReturn(0.5); + IrisObjectPlacement config = placement().setBore(true); + config.setSlopeCondition(new IrisSlopeClip(1, 2)); + RNG rng = new RNG(91); + RecordingPlacer placer = new RecordingPlacer(engine); + + assertEquals(-1, lineObject(1).place(0, ANCHOR_Y, 0, placer, config, rng, data)); + assertEquals(new RNG(91).nextLong(), rng.nextLong()); + assertTrue(placer.writes().isEmpty()); + } + + @Test + @SuppressWarnings("unchecked") + public void slopeRotationUsesAPerPlacementCopy() { + IrisComplex complex = mock(IrisComplex.class); + ProceduralStream heights = mock(ProceduralStream.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.getHeightStream()).thenReturn(heights); + when(heights.get(anyDouble(), anyDouble())).thenAnswer(call -> call.getArgument(0) > 0 ? 70D : 80D); + IrisObjectPlacement config = placement().setRotateTowardsSlope(true); + IrisObjectRotation original = config.getRotation(); + RecordingPlacer placer = new RecordingPlacer(engine); + + lineObject(3).place(0, ANCHOR_Y, 0, placer, config, new RNG(2L), data); + + assertSame(original, config.getRotation()); + assertEquals(0, original.getYAxis().getMin(), 0); + assertEquals(3, placer.writes().size()); + assertTrue(placer.writes().stream().allMatch(write -> write.x() == 0)); + assertEquals(List.of(-1, 0, 1), placer.writes().stream().map(BlockWrite::z).sorted().toList()); + } + + private PlacementState execute(IrisObjectPlacement config, int y, RecordingPlacer placer, CarveResult cave) { + return PlacementLifecycles.defaults().get(config.getMode()).execute( + new PlacementContext(config, data, placer, cave, new RNG(2L), lineObject(1), 0, 0, y, null)); + } + + private IrisObject lineObject(int width) { + IrisObject object = new IrisObject(width, 1, 1); + for (int x = 0; x < width; x++) { + object.setUnsigned(x, 0, 0, solid); + } + return object; + } + + private IrisObject boxObject(int width, int height, int depth) { + IrisObject object = new IrisObject(width, height, depth); + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + for (int z = 0; z < depth; z++) { + object.setUnsigned(x, y, z, solid); + } + } + } + return object; + } + + private IrisObject object(String key) { + IrisObject object = lineObject(1); + object.setLoadKey(key); + return object; + } + + private static NativeBlockState state(String key, boolean solid) { + NativeBlockState state = mock(NativeBlockState.class); + when(state.key()).thenReturn(key); + when(state.materialKey()).thenReturn(key); + when(state.isSolid()).thenReturn(solid); + when(state.isOccluding()).thenReturn(solid); + return state; + } + + private static final class RecordingPlacer implements IObjectPlacer { + private final List writes = new ArrayList<>(); + private final Map world = new HashMap<>(); + private final List sampledColumns = new ArrayList<>(); + private final Engine engine; + private int failAfterWrites = Integer.MAX_VALUE; + private boolean debugSmartBore; + private boolean carved; + private boolean underwater; + private int fluidHeight; + + private RecordingPlacer(Engine engine) { + this.engine = engine; + } + + private void failAfterWrites(int writes) { + failAfterWrites = writes; + } + + private void setDebugSmartBore(boolean debugSmartBore) { + this.debugSmartBore = debugSmartBore; + } + + private List writes() { + return writes; + } + + private List writesOf(NativeBlockState state) { + return writes.stream().filter(write -> write.state() == state).toList(); + } + + private List sampledColumns() { + return sampledColumns; + } + + @Override + public int getHighest(int x, int z, IrisData data) { + sampledColumns.add(x + ":" + z); + return ANCHOR_Y; + } + + @Override + public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { + sampledColumns.add(x + ":" + z); + return ANCHOR_Y; + } + + @Override + public void set(int x, int y, int z, NativeBlockState state) { + if (writes.size() >= failAfterWrites) { + throw new IllegalStateException("write failed"); + } + writes.add(new BlockWrite(x, y, z, state)); + world.put(x + ":" + y + ":" + z, state); + } + + @Override + public NativeBlockState get(int x, int y, int z) { + return world.get(x + ":" + y + ":" + z); + } + + @Override + public boolean isPreventingDecay() { + return false; + } + + @Override + public boolean isCarved(int x, int y, int z) { + return carved; + } + + @Override + public boolean isSolid(int x, int y, int z) { + NativeBlockState state = get(x, y, z); + return state != null && state.isSolid(); + } + + @Override + public boolean isUnderwater(int x, int z) { + return underwater; + } + + @Override + public int getFluidHeight() { + return fluidHeight; + } + + @Override + public boolean isDebugSmartBore() { + return debugSmartBore; + } + + @Override + public void setTile(int x, int y, int z, TileData tile) { + } + + @Override + public void setData(int x, int y, int z, T data) { + } + + @Override + public T getData(int x, int y, int z, Class type) { + return null; + } + + @Override + public Engine getEngine() { + return engine; + } + } + + private record BlockWrite(int x, int y, int z, NativeBlockState state) { + } +} diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectStructurePieceAirTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectStructurePieceAirTest.java index 163b7127c..b7b0459c8 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectStructurePieceAirTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectStructurePieceAirTest.java @@ -8,20 +8,20 @@ import static org.junit.Assert.assertTrue; public class IrisObjectStructurePieceAirTest { @Test public void explicitAirPlacesOnlyForRawStructurePieces() { - assertTrue(IrisObjectPlacementRunner.shouldPlaceObjectBlock(true, true, false)); - assertFalse(IrisObjectPlacementRunner.shouldPlaceObjectBlock(false, true, false)); + assertTrue(ObjectBlockPass.shouldPlaceObjectBlock(true, true, false)); + assertFalse(ObjectBlockPass.shouldPlaceObjectBlock(false, true, false)); } @Test public void ordinaryObjectAirRemainsNondestructive() { - assertFalse(IrisObjectPlacementRunner.shouldPlaceObjectBlock(false, true, false)); - assertTrue(IrisObjectPlacementRunner.shouldPlaceObjectBlock(false, false, false)); + assertFalse(ObjectBlockPass.shouldPlaceObjectBlock(false, true, false)); + assertTrue(ObjectBlockPass.shouldPlaceObjectBlock(false, false, false)); } @Test public void vineReplacementRemainsRejectedForEveryPlacementMode() { - assertFalse(IrisObjectPlacementRunner.shouldPlaceObjectBlock(false, false, true)); - assertFalse(IrisObjectPlacementRunner.shouldPlaceObjectBlock(true, false, true)); - assertFalse(IrisObjectPlacementRunner.shouldPlaceObjectBlock(true, true, true)); + assertFalse(ObjectBlockPass.shouldPlaceObjectBlock(false, false, true)); + assertFalse(ObjectBlockPass.shouldPlaceObjectBlock(true, false, true)); + assertFalse(ObjectBlockPass.shouldPlaceObjectBlock(true, true, true)); } } diff --git a/core/src/test/java/art/arcane/iris/structure/object/PlacementLifecycleTest.java b/core/src/test/java/art/arcane/iris/structure/object/PlacementLifecycleTest.java new file mode 100644 index 000000000..e65fd01ea --- /dev/null +++ b/core/src/test/java/art/arcane/iris/structure/object/PlacementLifecycleTest.java @@ -0,0 +1,200 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class PlacementLifecycleTest { + private static PlacementLifecycleDependency id(String name) { + return new PlacementLifecycleDependency(name); + } + + @Test + public void resolvesDependenciesAcrossModulesRegardlessOfInstallOrder() { + List calls = new ArrayList<>(); + PlacementModule consumer = builder -> builder.add(id("consumer"), PlacementStage.HEIGHT, + (context, state, ignored) -> calls.add("consumer"), id("provider")); + PlacementModule provider = builder -> builder.add(id("provider"), PlacementStage.HEIGHT, + (context, state, ignored) -> calls.add("provider")); + PlacementLifecycleBuilder builder = new PlacementLifecycleBuilder(); + consumer.install(builder); + provider.install(builder); + builder.add(id("independent"), PlacementStage.HEIGHT, + (context, state, ignored) -> calls.add("independent")); + + PlacementLifecycle plan = builder.build(); + assertTrue(calls.isEmpty()); + plan.run(PlacementStage.HEIGHT, null, new PlacementState(0, null), null); + assertEquals(List.of("provider", "consumer", "independent"), calls); + } + + @Test + public void rejectsMissingDuplicateCyclicAndBackwardDependencies() { + PlacementLifecycleBuilder missing = new PlacementLifecycleBuilder(); + missing.add(id("stilt"), PlacementStage.AFTER_BLOCKS, (c, s, v) -> {}, id("bounds")); + String message = assertThrows(IllegalArgumentException.class, missing::build).getMessage(); + assertTrue(message.contains("stilt")); + assertTrue(message.contains("bounds")); + + PlacementLifecycleBuilder duplicate = new PlacementLifecycleBuilder(); + duplicate.add(id("a"), PlacementStage.HEIGHT, (c, s, v) -> {}); + assertThrows(IllegalArgumentException.class, + () -> duplicate.add(id("a"), PlacementStage.HEIGHT, (c, s, v) -> {})); + + PlacementLifecycleBuilder cyclic = new PlacementLifecycleBuilder(); + cyclic.add(id("a"), PlacementStage.HEIGHT, (c, s, v) -> {}, id("b")); + cyclic.add(id("b"), PlacementStage.HEIGHT, (c, s, v) -> {}, id("a")); + message = assertThrows(IllegalArgumentException.class, cyclic::build).getMessage(); + assertTrue(message.contains("cycle")); + assertTrue(message.contains("a")); + assertTrue(message.contains("b")); + + PlacementLifecycleBuilder backward = new PlacementLifecycleBuilder(); + backward.add(id("height"), PlacementStage.HEIGHT, (c, s, v) -> {}, id("fill")); + backward.add(id("fill"), PlacementStage.AFTER_BLOCKS, (c, s, v) -> {}); + assertTrue(assertThrows(IllegalArgumentException.class, backward::build) + .getMessage().contains("later step")); + } + + @Test + public void rejectsSelfDependencyAndAcceptsRepeatedPrerequisite() { + PlacementLifecycleBuilder self = new PlacementLifecycleBuilder(); + self.add(id("self"), PlacementStage.HEIGHT, (c, s, v) -> {}, id("self")); + assertThrows(IllegalArgumentException.class, self::build); + + PlacementLifecycleBuilder repeated = new PlacementLifecycleBuilder(); + repeated.add(id("a"), PlacementStage.HEIGHT, (c, s, v) -> s.y++); + repeated.add(id("b"), PlacementStage.HEIGHT, (c, s, v) -> s.y *= 2, id("a"), id("a")); + PlacementState state = new PlacementState(2, null); + repeated.build().run(PlacementStage.HEIGHT, null, state, null); + assertEquals(6, state.y); + } + + @Test + public void plansAreSnapshotsAndFailuresPropagateWithoutRunningDependents() { + List calls = new ArrayList<>(); + PlacementLifecycleBuilder builder = new PlacementLifecycleBuilder(); + builder.add(id("first"), PlacementStage.HEIGHT, (c, s, v) -> calls.add("first")); + PlacementLifecycle original = builder.build(); + IllegalStateException failure = new IllegalStateException("failed placement"); + builder.add(id("failure"), PlacementStage.HEIGHT, (c, s, v) -> { throw failure; }, id("first")); + builder.add(id("dependent"), PlacementStage.HEIGHT, (c, s, v) -> calls.add("dependent"), id("failure")); + original.run(PlacementStage.HEIGHT, null, new PlacementState(0, null), null); + assertEquals(List.of("first"), calls); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> builder.build().run(PlacementStage.HEIGHT, null, new PlacementState(0, null), null))); + assertEquals(List.of("first", "first"), calls); + } + + @Test + public void everyModeResolvesAndRuntimePlansAreIndependent() { + PlacementLifecycles first = PlacementLifecycles.resolve(); + PlacementLifecycles second = PlacementLifecycles.resolve(); + for (ObjectPlaceMode mode : ObjectPlaceMode.values()) { + assertNotNull(first.get(mode)); + assertNotSame(first.get(mode), second.get(mode)); + } + } + + @Test + public void resolvesLongDependencyChainsWithoutRecursiveTraversal() { + PlacementLifecycleBuilder builder = new PlacementLifecycleBuilder(); + for (int i = 9999; i >= 0; i--) { + builder.add(id("step-" + i), PlacementStage.HEIGHT, (c, s, v) -> s.y++, + i == 0 ? new PlacementLifecycleDependency[0] + : new PlacementLifecycleDependency[]{id("step-" + (i - 1))}); + } + PlacementState state = new PlacementState(0, null); + builder.build().run(PlacementStage.HEIGHT, null, state, null); + assertEquals(10000, state.y); + } + + @Test + public void sharedHeightPlanKeepsConcurrentPlacementStateSeparate() throws Exception { + PlacementLifecycleBuilder builder = new PlacementLifecycleBuilder(); + new PlacementModulePreparation().install(builder); + new PlacementModuleSlope().install(builder); + new PlacementModuleHeight(PlacementModuleHeight.Mode.MAX, false).install(builder); + PlacementLifecycle plan = builder.build(); + CountDownLatch sampled = new CountDownLatch(2); + try (var executor = Executors.newFixedThreadPool(2)) { + var first = executor.submit(() -> sample(plan, 10, sampled)); + var second = executor.submit(() -> sample(plan, 100, sampled)); + assertEquals(11, (int) first.get(10, TimeUnit.SECONDS)); + assertEquals(101, (int) second.get(10, TimeUnit.SECONDS)); + } + } + + private int sample(PlacementLifecycle plan, int x, CountDownLatch sampled) throws Exception { + IObjectPlacer placer = mock(IObjectPlacer.class); + when(placer.getHighest(anyInt(), anyInt(), any(), anyBoolean())).thenAnswer(call -> { + sampled.countDown(); + assertTrue(sampled.await(5, TimeUnit.SECONDS)); + return call.getArgument(0); + }); + IrisObjectPlacement config = new IrisObjectPlacement().setForcePlace(true); + IrisObject object = mock(IrisObject.class); + when(object.getCenter()).thenReturn(new art.arcane.volmlib.util.math.Vector3i(1, 0, 0)); + when(object.getW()).thenReturn(3); + when(object.getH()).thenReturn(1); + when(object.getD()).thenReturn(1); + PlacementContext context = new PlacementContext(config, mock(IrisData.class), placer, null, + new RNG(1), object, x, 0, -1, null); + PlacementState state = new PlacementState(context); + plan.run(PlacementStage.PREPARE, context, state, null); + plan.run(PlacementStage.HEIGHT, context, state, null); + return state.y; + } + + @Test + public void guardOwnsTheForcePlaceExemption() { + PlacementLifecycleBuilder builder = new PlacementLifecycleBuilder(); + builder.add(id("guard"), PlacementStage.HEIGHT, (c, s, v) -> { + if (!s.config.isForcePlace()) { + s.reject("guard"); + } + }); + builder.add(id("dependent"), PlacementStage.HEIGHT, (c, s, v) -> s.y = 99, id("guard")); + PlacementLifecycle plan = builder.build(); + IrisObjectPlacement config = new IrisObjectPlacement(); + PlacementContext context = new PlacementContext(config, mock(IrisData.class), mock(IObjectPlacer.class), + null, new RNG(1), mock(IrisObject.class), 0, 0, -1, null); + PlacementState rejected = new PlacementState(context); + plan.run(PlacementStage.HEIGHT, context, rejected, null); + assertEquals(-1, rejected.heightOrRejected()); + assertEquals("guard", rejected.getRejection()); + config.setForcePlace(true); + PlacementState forced = new PlacementState(context); + plan.run(PlacementStage.HEIGHT, context, forced, null); + assertEquals(99, forced.y); + } + + @Test + public void rejectionIsTerminalAcrossPhasesEvenWithForcePlace() { + PlacementLifecycleBuilder builder = new PlacementLifecycleBuilder(); + List calls = new ArrayList<>(); + builder.add(id("reject"), PlacementStage.VALIDATE, (c, s, v) -> s.reject("protected")); + builder.add(id("bore"), PlacementStage.BEFORE_BLOCKS, (c, s, v) -> calls.add("bore")); + builder.add(id("blocks"), PlacementStage.BLOCKS, (c, s, v) -> calls.add("blocks")); + builder.add(id("finish"), PlacementStage.AFTER_BLOCKS, (c, s, v) -> calls.add("finish")); + PlacementContext context = new PlacementContext(new IrisObjectPlacement().setForcePlace(true), + mock(IrisData.class), mock(IObjectPlacer.class), null, new RNG(1), + mock(IrisObject.class), 0, 0, 80, null); + PlacementState result = builder.build().execute(context); + assertEquals(-1, result.heightOrRejected()); + result.reject("later reason"); + assertEquals("protected", result.getRejection()); + assertTrue(calls.isEmpty()); + } +}