mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-26 05:50:39 +00:00
Rewrite object placement as resolved lifecycle modules
This commit is contained in:
1 parent
2d211cf106
commit
46d458e6e4
44 files changed
+3156
-1449
No files matched your search
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Long> 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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
+6
-1413
File diff suppressed because it is too large.
Load diff
@@ -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<BlockPosition, NativeBlockState> 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<BlockPosition, NativeBlockState> 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<TileData> 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) {}
|
||||
}
|
||||
@@ -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<NativeBlockState> blocks = self.blocks;
|
||||
KMap<IrisBlockVector, String> markers = new KMap<>();
|
||||
KList<IrisBlockVector> 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<NativeBlockState>.Cursor cursor = blocks.cursor();
|
||||
int index = 0;
|
||||
while (index < resolved.length && cursor.next()) {
|
||||
resolved[index++] = markers.get(cursor.key());
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
@@ -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<? extends PlacementModule> factory;
|
||||
|
||||
ObjectPlaceMode(Supplier<? extends PlacementModule> 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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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<BlockPosition, NativeBlockState> listener;
|
||||
|
||||
public PlacementContext(IrisObjectPlacement config, IrisData rdata, IObjectPlacer placer,
|
||||
CarveResult caveResult, RNG rng, IrisObject self, int x, int z, int yv,
|
||||
BiConsumer<BlockPosition, NativeBlockState> 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<BlockPosition, NativeBlockState> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package art.arcane.iris.structure.object;
|
||||
|
||||
|
||||
@FunctionalInterface
|
||||
public interface PlacementFunction<C> {
|
||||
void execute(PlacementContext context, PlacementState state, C input);
|
||||
}
|
||||
@@ -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<List<PlacementLifecycleBuilder.Step<?>>> stages;
|
||||
|
||||
PlacementLifecycle(List<PlacementLifecycleBuilder.Step<?>> 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<Void> 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 <C> void run(PlacementStage<C> 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<C> function = (PlacementFunction<C>) step.function();
|
||||
function.execute(context, state, input);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* lifecycle.add(FILL, PlacementStage.AFTER_BLOCKS, this::fill, HEIGHT, BOUNDS);
|
||||
* }</pre>
|
||||
*
|
||||
* 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<PlacementLifecycleDependency, Step<?>> steps = new LinkedHashMap<>();
|
||||
|
||||
public <C> void add(PlacementLifecycleDependency id, PlacementStage<C> stage,
|
||||
PlacementFunction<C> function, PlacementLifecycleDependency... after) {
|
||||
Objects.requireNonNull(id, "Step id");
|
||||
Objects.requireNonNull(stage, "Stage");
|
||||
Objects.requireNonNull(function, "Function");
|
||||
Step<C> 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<PlacementLifecycleDependency, Integer> remaining = new HashMap<>();
|
||||
Map<PlacementLifecycleDependency, List<Step<?>>> dependents = new HashMap<>();
|
||||
PriorityQueue<Step<?>> ready = new PriorityQueue<>(Comparator.<Step<?>>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<Step<?>> 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<C>(PlacementLifecycleDependency id, PlacementStage<C> stage, PlacementFunction<C> function,
|
||||
List<PlacementLifecycleDependency> after, int index) {
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ObjectPlaceMode, PlacementLifecycle> plans;
|
||||
|
||||
private PlacementLifecycles(Map<ObjectPlaceMode, PlacementLifecycle> plans) {
|
||||
this.plans = Map.copyOf(plans);
|
||||
}
|
||||
|
||||
public static PlacementLifecycles resolve() {
|
||||
Map<ObjectPlaceMode, PlacementLifecycle> 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Void> 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<NativeBlockState>.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;
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+168
@@ -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<NativeStructureVolume> 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<NativeBlockState>.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<NativeStructureVolume> volumes;
|
||||
private final Long2LongOpenHashMap columns = new Long2LongOpenHashMap();
|
||||
|
||||
private SurfaceCuts(Engine engine, KList<NativeStructureVolume> 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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<IrisBlockVector> transformedPaintSupport(IrisObject self, SpinKernel spin, IrisBlockVector translateOffset,
|
||||
CNG warp, double half, int x, int y, int z) {
|
||||
KList<IrisBlockVector> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<Double> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -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<String, Long> 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 ? "<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 ? "<no loadKey>" : 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.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> excludedStiltMaterials;
|
||||
private final NativeBlockState[] decayed;
|
||||
private final Facts[] facts;
|
||||
private final byte[] stiltLayers;
|
||||
private final byte[] stilts;
|
||||
private final NativeBlockState[] stiltDefaults;
|
||||
private IdentityHashMap<NativeBlockState, Object> rotations;
|
||||
private IdentityHashMap<NativeBlockState, Facts> editedFacts;
|
||||
private IdentityHashMap<NativeBlockState, Boolean> placedStilts;
|
||||
|
||||
PlacementPalette(NativeBlockState[] palette, IrisObjectRotation rotation, int spinX, int spinY, int spinZ,
|
||||
boolean preventingDecay, boolean debugSmartBore, Set<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<C> {
|
||||
public static final PlacementStage<Void> PREPARE = new PlacementStage<>("prepare", 0);
|
||||
public static final PlacementStage<Void> HEIGHT = new PlacementStage<>("height", 1);
|
||||
public static final PlacementStage<Void> VALIDATE = new PlacementStage<>("validate", 2);
|
||||
public static final PlacementStage<Void> BEFORE_BLOCKS = new PlacementStage<>("before-blocks", 3);
|
||||
public static final PlacementStage<PlacementBlockWrite> 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<PlacementLifecycle> BLOCKS = new PlacementStage<>("blocks", 5);
|
||||
public static final PlacementStage<Void> AFTER_BLOCKS = new PlacementStage<>("after-blocks", 6);
|
||||
static final List<PlacementStage<?>> ALL = List.of(PREPARE, HEIGHT, VALIDATE, BEFORE_BLOCKS,
|
||||
BLOCK_WRITTEN, BLOCKS, AFTER_BLOCKS);
|
||||
static final List<PlacementStage<Void>> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<IrisBlockVector> paintSupport;
|
||||
TreeGrounding.Plan grounding;
|
||||
ObjectPlacementBake bake;
|
||||
PlacementPalette palette;
|
||||
int vacuumLowest = Integer.MAX_VALUE;
|
||||
int vacuumHighest = Integer.MIN_VALUE;
|
||||
KMap<Position2, Integer> 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;
|
||||
}
|
||||
}
|
||||
+62
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+617
@@ -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<String, NativeBlockState> 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<BlockWrite> 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<BlockWrite> 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<Integer> 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<BlockWrite> 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<Double> 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<Double> heights = mock(ProceduralStream.class);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(complex.getHeightStream()).thenReturn(heights);
|
||||
when(heights.get(anyDouble(), anyDouble())).thenAnswer(call -> call.<Double>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<BlockWrite> writes = new ArrayList<>();
|
||||
private final Map<String, NativeBlockState> world = new HashMap<>();
|
||||
private final List<String> 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<BlockWrite> writes() {
|
||||
return writes;
|
||||
}
|
||||
|
||||
private List<BlockWrite> writesOf(NativeBlockState state) {
|
||||
return writes.stream().filter(write -> write.state() == state).toList();
|
||||
}
|
||||
|
||||
private List<String> 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 <T> void setData(int x, int y, int z, T data) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T getData(int x, int y, int z, Class<T> type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Engine getEngine() {
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
|
||||
private record BlockWrite(int x, int y, int z, NativeBlockState state) {
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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.<Integer>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<String> 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user