Gap-free object rotation

- Rotations off the right angles fill every target cell from the nearest source block
- Source blocks that no target cell samples still place at their rotated position
- Right-angle rotations keep the direct transform
- Markers, canopy and tile data follow their resampled source blocks
- Datapack export bakes off-angle variants with the same resampler
- The unused rotateCopy paths are removed
- Placement goldens match the resampled output
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-31 19:23:25 -04:00
1 parent 2462330787
commit b0860f94fc
14 files changed
+874 -189

No files matched your search

@@ -6,6 +6,7 @@ import art.arcane.iris.pack.datapack.export.TemplateVolume;
import art.arcane.iris.pack.datapack.export.VanillaBlockState; import art.arcane.iris.pack.datapack.export.VanillaBlockState;
import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier;
import art.arcane.iris.pack.datapack.export.compile.WeightedState; import art.arcane.iris.pack.datapack.export.compile.WeightedState;
import art.arcane.iris.structure.object.RotationResampler;
import art.arcane.volmlib.util.nbt.tag.CompoundTag; import art.arcane.volmlib.util.nbt.tag.CompoundTag;
import java.util.ArrayList; import java.util.ArrayList;
@@ -16,6 +17,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.TreeMap; import java.util.TreeMap;
import java.util.function.ToIntFunction;
public final class ObjectVariantBaker { public final class ObjectVariantBaker {
private static final Set<String> AIR = Set.of("minecraft:air", "minecraft:cave_air", "minecraft:void_air"); private static final Set<String> AIR = Set.of("minecraft:air", "minecraft:cave_air", "minecraft:void_air");
@@ -35,6 +37,10 @@ public final class ObjectVariantBaker {
.comparingInt(Voxel::y) .comparingInt(Voxel::y)
.thenComparingInt(Voxel::x) .thenComparingInt(Voxel::x)
.thenComparingInt(Voxel::z); .thenComparingInt(Voxel::z);
private static final Comparator<Voxel> PLACEMENT_ORDER = Comparator
.comparingInt(Voxel::x)
.thenComparingInt(Voxel::y)
.thenComparingInt(Voxel::z);
public static String templateId(String namespace, String objectPath, VariantSpec spec, DataVersion target) { public static String templateId(String namespace, String objectPath, VariantSpec spec, DataVersion target) {
VanillaResourceIdentifier.requireNamespace(namespace, "template namespace"); VanillaResourceIdentifier.requireNamespace(namespace, "template namespace");
@@ -59,7 +65,7 @@ public final class ObjectVariantBaker {
sizeZ = scaledSize(sizeZ, spec.scale()); sizeZ = scaledSize(sizeZ, spec.scale());
voxels = scale(voxels, spec.scale(), sizeX, sizeY, sizeZ); voxels = scale(voxels, spec.scale(), sizeX, sizeY, sizeZ);
} }
voxels = anchor(voxels, spec.rotation(), sizeX / 2, sizeY / 2, sizeZ / 2); voxels = anchor(templateId, voxels, spec.rotation(), sizeX / 2, sizeY / 2, sizeZ / 2);
if (spec.warp() != null) { if (spec.warp() != null) {
voxels = warp(voxels, spec.warp()); voxels = warp(voxels, spec.warp());
} }
@@ -243,11 +249,19 @@ public final class ObjectVariantBaker {
return new ArrayList<>(scaled.values()); return new ArrayList<>(scaled.values());
} }
private static List<Voxel> anchor(List<Voxel> voxels, VariantSpec.Rotation rotation, int centerX, int centerY, int centerZ) { private static List<Voxel> anchor(String templateId, List<Voxel> voxels, VariantSpec.Rotation rotation, int centerX,
int centerY, int centerZ) {
BlockStateRotation states = BlockStateRotation.of(rotation.x(), rotation.y(), rotation.z()); BlockStateRotation states = BlockStateRotation.of(rotation.x(), rotation.y(), rotation.z());
double[] lift = rotate(0, centerY, 0, rotation); double[] lift = rotate(0, centerY, 0, rotation);
int rotatedCenterY = (int) Math.floor(lift[1]); int rotatedCenterY = (int) Math.floor(lift[1]);
List<Voxel> sorted = new ArrayList<>(voxels); List<Voxel> sorted = new ArrayList<>(voxels);
if (!rotation.rightAngles()) {
sorted.sort(PLACEMENT_ORDER);
RotationResampler.Targets targets = RotationResampler.of(templateId, offsets(sorted, Voxel::x, centerX),
offsets(sorted, Voxel::y, centerY), offsets(sorted, Voxel::z, centerZ))
.resample(rotation.x(), rotation.y(), rotation.z());
return resampled(sorted, targets, states, rotatedCenterY);
}
sorted.sort(VOXEL_ORDER); sorted.sort(VOXEL_ORDER);
Map<Long, Voxel> anchored = new LinkedHashMap<>(); Map<Long, Voxel> anchored = new LinkedHashMap<>();
for (Voxel voxel : sorted) { for (Voxel voxel : sorted) {
@@ -260,6 +274,35 @@ public final class ObjectVariantBaker {
return new ArrayList<>(anchored.values()); return new ArrayList<>(anchored.values());
} }
private static int[] offsets(List<Voxel> voxels, ToIntFunction<Voxel> axis, int center) {
int[] offsets = new int[voxels.size()];
for (int index = 0; index < offsets.length; index++) {
offsets[index] = axis.applyAsInt(voxels.get(index)) - center;
}
return offsets;
}
private static List<Voxel> resampled(List<Voxel> sources, RotationResampler.Targets targets, BlockStateRotation states,
int liftY) {
int[] x = targets.x();
int[] y = targets.y();
int[] z = targets.z();
int[] origins = targets.sources();
VanillaBlockState[] turned = new VanillaBlockState[sources.size()];
List<Voxel> anchored = new ArrayList<>(origins.length);
for (int index = 0; index < origins.length; index++) {
int origin = origins[index];
Voxel source = sources.get(origin);
VanillaBlockState state = turned[origin];
if (state == null) {
state = states.apply(source.state());
turned[origin] = state;
}
anchored.add(new Voxel(x[index], y[index] + liftY, z[index], state, source.nbt(), source.root()));
}
return anchored;
}
private static double[] rotate(double x, double y, double z, VariantSpec.Rotation rotation) { private static double[] rotate(double x, double y, double z, VariantSpec.Rotation rotation) {
double rx = x; double rx = x;
double ry = y; double ry = y;
@@ -44,6 +44,10 @@ public record VariantSpec(double scale, Rotation rotation, VariantWarp warp, dou
public boolean none() { public boolean none() {
return x == 0 && y == 0 && z == 0; return x == 0 && y == 0 && z == 0;
} }
public boolean rightAngles() {
return x % 90 == 0 && y % 90 == 0 && z % 90 == 0;
}
} }
public static final class Builder { public static final class Builder {
@@ -386,10 +386,6 @@ public class IrisObject extends IrisRegistrant {
}); });
} }
public IrisObject rotateCopy(IrisObjectRotation rt) {
return IrisObjectTransforms.rotateCopy(this, rt);
}
public IrisObject scaled(double scale, IrisObjectPlacementScaleInterpolator interpolation) { public IrisObject scaled(double scale, IrisObjectPlacementScaleInterpolator interpolation) {
return IrisObjectTransforms.scaled(this, scale, interpolation); return IrisObjectTransforms.scaled(this, scale, interpolation);
} }
@@ -168,14 +168,6 @@ public class IrisObjectRotation {
return getRotation(spin, zAxis); return getRotation(spin, zAxis);
} }
public IrisObject rotateCopy(IrisObject e) {
if (e == null) {
return null;
}
return e.rotateCopy(this);
}
public IrisBlockVector rotate(IrisBlockVector direction) { public IrisBlockVector rotate(IrisBlockVector direction) {
return rotate(direction, 0, 0, 0); return rotate(direction, 0, 0, 0);
} }
@@ -43,72 +43,12 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
/** /**
* Geometric transforms for {@link IrisObject}: rotation, scaling and the interpolated upscalers. * Geometric transforms for {@link IrisObject}: scaling and the interpolated upscalers.
*/ */
final class IrisObjectTransforms { final class IrisObjectTransforms {
private IrisObjectTransforms() { private IrisObjectTransforms() {
} }
static IrisObject rotateCopy(IrisObject self, IrisObjectRotation rt) {
IrisObject copy = self.copy();
rotate(copy, rt, 0, 0, 0);
return copy;
}
static void rotate(IrisObject self, IrisObjectRotation r, int spinx, int spiny, int spinz) {
self.writeLock.lock();
try {
VectorMap<NativeBlockState> d = new VectorMap<>();
VectorMap<TileData> dx = new VectorMap<>();
TreeGrounding grounding = self.getTreeGrounding();
Set<TreeBlockCanvas.Vec> canopy = grounding == null ? null : new HashSet<>();
for (Map.Entry<IrisBlockVector, NativeBlockState> entry : self.blocks) {
NativeBlockState rotated = r.rotate(entry.getValue(), spinx, spiny, spinz);
if (rotated == null) {
continue;
}
IrisBlockVector destination = r.rotate(entry.getKey(), spinx, spiny, spinz);
d.put(destination, rotated);
if (canopy != null) {
TreeBlockCanvas.Vec cell = new TreeBlockCanvas.Vec(destination.getBlockX(),
destination.getBlockY(), destination.getBlockZ());
IrisBlockVector source = entry.getKey();
if (grounding.isCanopy(source.getBlockX(), source.getBlockY(), source.getBlockZ())) {
canopy.add(cell);
} else {
canopy.remove(cell);
}
}
TileData tile = self.states.get(entry.getKey());
if (tile == null) {
dx.remove(destination);
} else {
dx.put(destination, tile);
}
}
self.blocks = d;
self.states = dx;
if (grounding != null) {
List<TreeGrounding.Root> roots = new ArrayList<>();
for (TreeGrounding.Root root : grounding.roots()) {
IrisBlockVector position = r.rotate(new IrisBlockVector(root.x(), root.y(), root.z()), spinx, spiny, spinz);
NativeBlockState state = d.get(position);
if (state != null) {
roots.add(new TreeGrounding.Root(position.getBlockX(), position.getBlockY(), position.getBlockZ(), state));
}
}
self.setTreeGrounding(TreeGrounding.create(roots, grounding.maxExtension(), canopy));
}
IrisObjectShaping.shrinkwrap(self);
self.surfaceSupportOffsets.reset();
self.floatingFootprint.reset();
} finally {
self.writeLock.unlock();
}
}
static IrisObject scaled(IrisObject self, double scale, IrisObjectPlacementScaleInterpolator interpolation) { static IrisObject scaled(IrisObject self, double scale, IrisObjectPlacementScaleInterpolator interpolation) {
return scaled(self, scale, interpolation, ScaleOrigin.DEFAULT); return scaled(self, scale, interpolation, ScaleOrigin.DEFAULT);
} }
@@ -67,12 +67,12 @@ final class ObjectMarkerPlacement {
} }
} }
String[] resolved = new String[bake.count()]; String[] bySource = new String[bake.sourceCount()];
VectorMap<NativeBlockState>.Cursor cursor = blocks.cursor(); VectorMap<NativeBlockState>.Cursor cursor = blocks.cursor();
int index = 0; int index = 0;
while (index < resolved.length && cursor.next()) { while (index < bySource.length && cursor.next()) {
resolved[index++] = markers.get(cursor.key()); bySource[index++] = markers.get(cursor.key());
} }
return resolved; return bake.markers(bySource);
} }
} }
@@ -8,6 +8,7 @@ import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import it.unimi.dsi.fastutil.ints.IntArrayList; import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.IdentityHashMap; import java.util.IdentityHashMap;
import java.util.List; import java.util.List;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -21,15 +22,25 @@ final class ObjectPlacementBake {
private final int[] offsetY; private final int[] offsetY;
private final int[] offsetZ; private final int[] offsetZ;
private final int[] layerY; private final int[] layerY;
private final short[] paletteIndexes;
private final long[] canopy;
private final int[] tileBlocks;
private final TileData[] tileValues;
private final int[] sources;
private volatile double[] exact; private volatile double[] exact;
private ObjectPlacementBake(Layout layout, Key key, int[] offsetX, int[] offsetY, int[] offsetZ, int[] layerY) { private ObjectPlacementBake(Layout layout, Key key, Cells cells) {
this.layout = layout; this.layout = layout;
this.key = key; this.key = key;
this.offsetX = offsetX; this.offsetX = cells.offsetX();
this.offsetY = offsetY; this.offsetY = cells.offsetY();
this.offsetZ = offsetZ; this.offsetZ = cells.offsetZ();
this.layerY = layerY; this.layerY = cells.layerY();
this.paletteIndexes = cells.paletteIndexes();
this.canopy = cells.canopy();
this.tileBlocks = cells.tileBlocks();
this.tileValues = cells.tileValues();
this.sources = cells.sources();
} }
static ObjectPlacementBake of(IrisObject object, SpinKernel spin, boolean ceilingHang, IrisBlockVector translateOffset) { static ObjectPlacementBake of(IrisObject object, SpinKernel spin, boolean ceilingHang, IrisBlockVector translateOffset) {
@@ -42,7 +53,10 @@ final class ObjectPlacementBake {
if (cached != null && cached.layout == layout) { if (cached != null && cached.layout == layout) {
return cached; return cached;
} }
ObjectPlacementBake built = bake(object, layout, key, spin, ceilingHang, translateOffset); Cells cells = spin.axisAligned
? transformed(object, layout, spin, ceilingHang, translateOffset)
: resampled(object, layout, spin, ceilingHang, translateOffset);
ObjectPlacementBake built = new ObjectPlacementBake(layout, key, cells);
if (cached != null) { if (cached != null) {
variants.replace(key, cached, built); variants.replace(key, cached, built);
} else if (variants.size() < MAXIMUM_VARIANTS_PER_OBJECT) { } else if (variants.size() < MAXIMUM_VARIANTS_PER_OBJECT) {
@@ -54,8 +68,8 @@ final class ObjectPlacementBake {
} }
} }
private static ObjectPlacementBake bake(IrisObject object, Layout layout, Key key, SpinKernel spin, boolean ceilingHang, private static Cells transformed(IrisObject object, Layout layout, SpinKernel spin, boolean ceilingHang,
IrisBlockVector translateOffset) { IrisBlockVector translateOffset) {
int count = layout.count; int count = layout.count;
int[] x = new int[count]; int[] x = new int[count];
int[] y = new int[count]; int[] y = new int[count];
@@ -66,7 +80,7 @@ final class ObjectPlacementBake {
VectorMap<NativeBlockState>.Cursor cursor = object.blocks.cursor(); VectorMap<NativeBlockState>.Cursor cursor = object.blocks.cursor();
int index = 0; int index = 0;
while (cursor.next() && index < count) { while (cursor.next() && index < count) {
transform(cursor.key(), rotated, spin, ceilingHang, translateOffset); RotationResampler.transform(cursor.key(), rotated, spin, ceilingHang, translateOffset);
int roundedY = (int) Math.round(rotated.getY()); int roundedY = (int) Math.round(rotated.getY());
int floorY = rotated.getBlockY(); int floorY = rotated.getBlockY();
x[index] = (int) Math.round(rotated.getX()); x[index] = (int) Math.round(rotated.getX());
@@ -79,24 +93,46 @@ final class ObjectPlacementBake {
if (index != count) { if (index != count) {
throw new IllegalStateException("Object " + object.getLoadKey() + " changed while its placement bake was built"); throw new IllegalStateException("Object " + object.getLoadKey() + " changed while its placement bake was built");
} }
return new ObjectPlacementBake(layout, key, x, y, z, layerDiffers ? layer : null); return new Cells(x, y, z, layerDiffers ? layer : null, layout.paletteIndexes, layout.canopy, layout.tileBlocks,
layout.tileValues, null);
} }
private static void transform(IrisBlockVector source, IrisBlockVector target, SpinKernel spin, boolean ceilingHang, private static Cells resampled(IrisObject object, Layout layout, SpinKernel spin, boolean ceilingHang,
IrisBlockVector translateOffset) { IrisBlockVector translateOffset) {
target.setX(source.getX()); RotationResampler.Targets targets = layout.resampler(object).resample(spin, ceilingHang, translateOffset);
target.setY(source.getY()); return gather(layout, targets.x(), targets.y(), targets.z(), targets.sources());
target.setZ(source.getZ()); }
spin.rotate(target);
if (ceilingHang) { private static Cells gather(Layout layout, int[] x, int[] y, int[] z, int[] sources) {
target.setY(-target.getBlockY()); int count = sources.length;
} short[] paletteIndexes = new short[count];
if (translateOffset != null) { long[] canopy = layout.canopy == null ? null : new long[(count + 63) >>> 6];
target.add(translateOffset); IntArrayList tileBlocks = layout.tileBlocks == null ? null : new IntArrayList();
List<TileData> tileValues = layout.tileBlocks == null ? null : new ArrayList<>();
for (int index = 0; index < count; index++) {
int source = sources[index];
paletteIndexes[index] = layout.paletteIndexes[source];
if (canopy != null && (layout.canopy[source >>> 6] & (1L << (source & 63))) != 0L) {
canopy[index >>> 6] |= 1L << (index & 63);
}
if (tileBlocks != null) {
int slot = Arrays.binarySearch(layout.tileBlocks, source);
if (slot >= 0) {
tileBlocks.add(index);
tileValues.add(layout.tileValues[slot]);
}
}
} }
boolean tiled = tileBlocks != null && !tileBlocks.isEmpty();
return new Cells(x, y, z, null, paletteIndexes, canopy, tiled ? tileBlocks.toIntArray() : null,
tiled ? tileValues.toArray(new TileData[0]) : null, sources);
} }
int count() { int count() {
return offsetX.length;
}
int sourceCount() {
return layout.count; return layout.count;
} }
@@ -117,7 +153,7 @@ final class ObjectPlacementBake {
} }
int paletteIndex(int index) { int paletteIndex(int index) {
return layout.paletteIndexes[index]; return paletteIndexes[index];
} }
NativeBlockState[] palette() { NativeBlockState[] palette() {
@@ -125,32 +161,51 @@ final class ObjectPlacementBake {
} }
int[] tileBlocks() { int[] tileBlocks() {
return layout.tileBlocks; return tileBlocks;
} }
TileData[] tileValues() { TileData[] tileValues() {
return layout.tileValues; return tileValues;
} }
boolean canopy(int index) { boolean canopy(int index) {
long[] canopy = layout.canopy;
return canopy != null && (canopy[index >>> 6] & (1L << (index & 63))) != 0L; return canopy != null && (canopy[index >>> 6] & (1L << (index & 63))) != 0L;
} }
String[] markers(String[] sourceMarkers) {
if (sources == null) {
return sourceMarkers;
}
String[] unclaimed = sourceMarkers.clone();
String[] resolved = new String[sources.length];
for (int index = 0; index < sources.length; index++) {
int source = sources[index];
resolved[index] = unclaimed[source];
unclaimed[source] = null;
}
return resolved;
}
double[] exact(IrisObject object, SpinKernel spin) { double[] exact(IrisObject object, SpinKernel spin) {
double[] resolved = exact; double[] resolved = exact;
if (resolved != null) { if (resolved != null) {
return resolved; return resolved;
} }
resolved = sources == null ? transformedExact(object, spin) : targetExact();
exact = resolved;
return resolved;
}
private double[] transformedExact(IrisObject object, SpinKernel spin) {
int count = layout.count; int count = layout.count;
object.readLock.lock(); object.readLock.lock();
try { try {
resolved = new double[count * 3]; double[] resolved = new double[count * 3];
IrisBlockVector rotated = new IrisBlockVector(0, 0, 0); IrisBlockVector rotated = new IrisBlockVector(0, 0, 0);
VectorMap<NativeBlockState>.Cursor cursor = object.blocks.cursor(); VectorMap<NativeBlockState>.Cursor cursor = object.blocks.cursor();
int index = 0; int index = 0;
while (cursor.next() && index < count) { while (cursor.next() && index < count) {
transform(cursor.key(), rotated, spin, key.ceilingHang(), key.translateOffset()); RotationResampler.transform(cursor.key(), rotated, spin, key.ceilingHang(), key.translateOffset());
resolved[index * 3] = rotated.getX(); resolved[index * 3] = rotated.getX();
resolved[index * 3 + 1] = rotated.getY(); resolved[index * 3 + 1] = rotated.getY();
resolved[index * 3 + 2] = rotated.getZ(); resolved[index * 3 + 2] = rotated.getZ();
@@ -159,10 +214,19 @@ final class ObjectPlacementBake {
if (index != count) { if (index != count) {
throw new IllegalStateException("Object " + object.getLoadKey() + " changed while its placement bake was read"); throw new IllegalStateException("Object " + object.getLoadKey() + " changed while its placement bake was read");
} }
return resolved;
} finally { } finally {
object.readLock.unlock(); object.readLock.unlock();
} }
exact = resolved; }
private double[] targetExact() {
double[] resolved = new double[offsetX.length * 3];
for (int index = 0; index < offsetX.length; index++) {
resolved[index * 3] = offsetX[index];
resolved[index * 3 + 1] = offsetY[index];
resolved[index * 3 + 2] = offsetZ[index];
}
return resolved; return resolved;
} }
@@ -176,6 +240,7 @@ final class ObjectPlacementBake {
private final int[] tileBlocks; private final int[] tileBlocks;
private final TileData[] tileValues; private final TileData[] tileValues;
private final long[] canopy; private final long[] canopy;
private volatile RotationResampler resampler;
private Layout(long blocksRevision, long statesRevision, TreeGrounding grounding, int count, short[] paletteIndexes, private Layout(long blocksRevision, long statesRevision, TreeGrounding grounding, int count, short[] paletteIndexes,
NativeBlockState[] palette, int[] tileBlocks, TileData[] tileValues, long[] canopy) { NativeBlockState[] palette, int[] tileBlocks, TileData[] tileValues, long[] canopy) {
@@ -270,6 +335,15 @@ final class ObjectPlacementBake {
&& object.states.modificationRevision() == statesRevision && object.states.modificationRevision() == statesRevision
&& object.getTreeGrounding() == grounding; && object.getTreeGrounding() == grounding;
} }
private RotationResampler resampler(IrisObject object) {
RotationResampler resolved = resampler;
if (resolved == null) {
resolved = RotationResampler.of(object, count);
resampler = resolved;
}
return resolved;
}
} }
record Key(boolean rotates, int xMode, int yMode, int zMode, long xCos, long xSin, long yCos, long ySin, record Key(boolean rotates, int xMode, int yMode, int zMode, long xCos, long xSin, long yCos, long ySin,
@@ -291,4 +365,8 @@ final class ObjectPlacementBake {
Double.longBitsToDouble(translateY), Double.longBitsToDouble(translateZ)) : null; Double.longBitsToDouble(translateY), Double.longBitsToDouble(translateZ)) : null;
} }
} }
private record Cells(int[] offsetX, int[] offsetY, int[] offsetZ, int[] layerY, short[] paletteIndexes, long[] canopy,
int[] tileBlocks, TileData[] tileValues, int[] sources) {
}
} }
@@ -0,0 +1,205 @@
package art.arcane.iris.structure.object;
import art.arcane.iris.generation.block.VectorMap;
import art.arcane.iris.generation.geometry.IrisBlockVector;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import it.unimi.dsi.fastutil.ints.IntArrayList;
public final class RotationResampler {
private final int[] sourceX;
private final int[] sourceY;
private final int[] sourceZ;
private final Box sourceBox;
private final int[] sourceCells;
private RotationResampler(int[] sourceX, int[] sourceY, int[] sourceZ, Box sourceBox, int[] sourceCells) {
this.sourceX = sourceX;
this.sourceY = sourceY;
this.sourceZ = sourceZ;
this.sourceBox = sourceBox;
this.sourceCells = sourceCells;
}
public static RotationResampler of(String owner, int[] x, int[] y, int[] z) {
int count = x.length;
if (y.length != count || z.length != count) {
throw new IllegalArgumentException(owner + " has " + x.length + "/" + y.length + "/" + z.length
+ " source coordinates on its x/y/z axes");
}
if (count == 0) {
return new RotationResampler(x, y, z, new Box(0, 0, 0, 0, 0, 0), new int[1]);
}
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
int minZ = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int maxY = Integer.MIN_VALUE;
int maxZ = Integer.MIN_VALUE;
for (int index = 0; index < count; index++) {
minX = Math.min(minX, x[index]);
minY = Math.min(minY, y[index]);
minZ = Math.min(minZ, z[index]);
maxX = Math.max(maxX, x[index]);
maxY = Math.max(maxY, y[index]);
maxZ = Math.max(maxZ, z[index]);
}
Box box = new Box(minX, maxX, minY, maxY, minZ, maxZ);
if (box.volume() > Integer.MAX_VALUE - 8) {
throw new IllegalStateException(owner + " spans " + (maxX - minX + 1) + "x" + (maxY - minY + 1) + "x"
+ (maxZ - minZ + 1) + " blocks, too large to resample for a non-right-angle rotation");
}
int[] cells = new int[(int) box.volume()];
for (int index = 0; index < count; index++) {
cells[(int) box.cell(x[index], y[index], z[index])] = index + 1;
}
return new RotationResampler(x, y, z, box, cells);
}
static RotationResampler of(IrisObject object, int count) {
int[] x = new int[count];
int[] y = new int[count];
int[] z = new int[count];
VectorMap<NativeBlockState>.Cursor cursor = object.blocks.cursor();
int index = 0;
while (index < count && cursor.next()) {
IrisBlockVector position = cursor.key();
x[index] = position.getBlockX();
y[index] = position.getBlockY();
z[index] = position.getBlockZ();
index++;
}
if (index != count) {
throw new IllegalStateException("Object " + object.getLoadKey() + " changed while its placement grid was built");
}
return of("Object " + object.getLoadKey(), x, y, z);
}
public Targets resample(double xDegrees, double yDegrees, double zDegrees) {
return resample(new SpinKernel(IrisObjectRotation.of(xDegrees, yDegrees, zDegrees), 0, 0, 0), false, null);
}
Targets resample(SpinKernel spin, boolean ceilingHang, IrisBlockVector translateOffset) {
int count = sourceX.length;
Box bounds = targetBounds(spin, ceilingHang, translateOffset);
long[] occupied = new long[(int) ((bounds.volume() + 63L) >>> 6)];
long[] sampled = new long[(count + 63) >>> 6];
IntArrayList x = new IntArrayList(count);
IntArrayList y = new IntArrayList(count);
IntArrayList z = new IntArrayList(count);
IntArrayList sources = new IntArrayList(count);
IrisBlockVector probe = new IrisBlockVector(0, 0, 0);
for (int targetX = bounds.minX(); targetX <= bounds.maxX(); targetX++) {
for (int targetY = bounds.minY(); targetY <= bounds.maxY(); targetY++) {
for (int targetZ = bounds.minZ(); targetZ <= bounds.maxZ(); targetZ++) {
probe.setX(targetX);
probe.setY(targetY);
probe.setZ(targetZ);
if (translateOffset != null) {
probe.subtract(translateOffset);
}
if (ceilingHang) {
probe.setY(-probe.getY());
}
spin.inverseRotate(probe);
int source = source((int) Math.round(probe.getX()), (int) Math.round(probe.getY()),
(int) Math.round(probe.getZ()));
if (source < 0) {
continue;
}
long cell = bounds.cell(targetX, targetY, targetZ);
occupied[(int) (cell >>> 6)] |= 1L << (cell & 63L);
sampled[source >>> 6] |= 1L << (source & 63);
x.add(targetX);
y.add(targetY);
z.add(targetZ);
sources.add(source);
}
}
}
for (int source = 0; source < count; source++) {
if ((sampled[source >>> 6] & (1L << (source & 63))) != 0L) {
continue;
}
probe.setX(sourceX[source]);
probe.setY(sourceY[source]);
probe.setZ(sourceZ[source]);
transform(probe, probe, spin, ceilingHang, translateOffset);
int targetX = Math.clamp(Math.round(probe.getX()), bounds.minX(), bounds.maxX());
int targetY = Math.clamp(Math.round(probe.getY()), bounds.minY(), bounds.maxY());
int targetZ = Math.clamp(Math.round(probe.getZ()), bounds.minZ(), bounds.maxZ());
long cell = bounds.cell(targetX, targetY, targetZ);
if ((occupied[(int) (cell >>> 6)] & (1L << (cell & 63L))) == 0L) {
occupied[(int) (cell >>> 6)] |= 1L << (cell & 63L);
x.add(targetX);
y.add(targetY);
z.add(targetZ);
sources.add(source);
}
}
return new Targets(x.toIntArray(), y.toIntArray(), z.toIntArray(), sources.toIntArray());
}
static void transform(IrisBlockVector source, IrisBlockVector target, SpinKernel spin, boolean ceilingHang,
IrisBlockVector translateOffset) {
target.setX(source.getX());
target.setY(source.getY());
target.setZ(source.getZ());
spin.rotate(target);
if (ceilingHang) {
target.setY(-target.getBlockY());
}
if (translateOffset != null) {
target.add(translateOffset);
}
}
private int source(int x, int y, int z) {
return sourceBox.contains(x, y, z) ? sourceCells[(int) sourceBox.cell(x, y, z)] - 1 : -1;
}
private Box targetBounds(SpinKernel spin, boolean ceilingHang, IrisBlockVector translateOffset) {
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
int minZ = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int maxY = Integer.MIN_VALUE;
int maxZ = Integer.MIN_VALUE;
IrisBlockVector corner = new IrisBlockVector(0, 0, 0);
for (int index = 0; index < 8; index++) {
corner.setX((index & 1) == 0 ? sourceBox.minX() : sourceBox.maxX());
corner.setY((index & 2) == 0 ? sourceBox.minY() : sourceBox.maxY());
corner.setZ((index & 4) == 0 ? sourceBox.minZ() : sourceBox.maxZ());
transform(corner, corner, spin, ceilingHang, translateOffset);
int x = (int) Math.round(corner.getX());
int y = (int) Math.round(corner.getY());
int z = (int) Math.round(corner.getZ());
minX = Math.min(minX, x);
minY = Math.min(minY, y);
minZ = Math.min(minZ, z);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
maxZ = Math.max(maxZ, z);
}
return new Box(minX, maxX, minY, maxY, minZ, maxZ);
}
public record Targets(int[] x, int[] y, int[] z, int[] sources) {
public int count() {
return sources.length;
}
}
private record Box(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) {
private long volume() {
return (long) (maxX - minX + 1) * (maxY - minY + 1) * (maxZ - minZ + 1);
}
private boolean contains(int x, int y, int z) {
return x >= minX && x <= maxX && y >= minY && y <= maxY && z >= minZ && z <= maxZ;
}
private long cell(int x, int y, int z) {
return ((long) (x - minX) * (maxY - minY + 1) + (y - minY)) * (maxZ - minZ + 1) + (z - minZ);
}
}
}
@@ -17,8 +17,10 @@ final class SpinKernel {
static final int MODE_QUARTER = 2; static final int MODE_QUARTER = 2;
static final int MODE_THREE_QUARTER = 3; static final int MODE_THREE_QUARTER = 3;
static final int MODE_ANGLE = 4; static final int MODE_ANGLE = 4;
private static final double AXIS_ALIGNED_EPSILON = 1.0E-9D;
final boolean rotates; final boolean rotates;
final boolean axisAligned;
final int xMode; final int xMode;
final int yMode; final int yMode;
final int zMode; final int zMode;
@@ -44,6 +46,9 @@ final class SpinKernel {
zSin = Math.sin(zAngle); zSin = Math.sin(zAngle);
yCos = Math.cos(yAngle); yCos = Math.cos(yAngle);
ySin = Math.sin(yAngle); ySin = Math.sin(yAngle);
axisAligned = Math.abs(xCos * xSin) < AXIS_ALIGNED_EPSILON
&& Math.abs(zCos * zSin) < AXIS_ALIGNED_EPSILON
&& Math.abs(yCos * ySin) < AXIS_ALIGNED_EPSILON;
} }
private static int modeOf(IrisAxisRotationClamp clamp) { private static int modeOf(IrisAxisRotationClamp clamp) {
@@ -149,4 +154,85 @@ final class SpinKernel {
} }
} }
} }
void inverseRotate(IrisBlockVector v) {
if (!rotates) {
return;
}
switch (yMode) {
case MODE_FLIP -> {
v.setX(-v.getX());
v.setZ(-v.getZ());
}
case MODE_QUARTER -> {
double x = v.getX();
v.setX(-v.getZ());
v.setZ(x);
}
case MODE_THREE_QUARTER -> {
double x = v.getX();
v.setX(v.getZ());
v.setZ(-x);
}
case MODE_ANGLE -> {
double x = yCos * v.getX() - ySin * v.getZ();
double z = ySin * v.getX() + yCos * v.getZ();
v.setX(x);
v.setZ(z);
}
default -> {
}
}
switch (zMode) {
case MODE_FLIP -> {
v.setY(-v.getY());
v.setX(-v.getX());
}
case MODE_QUARTER -> {
double y = v.getY();
v.setY(-v.getX());
v.setX(y);
}
case MODE_THREE_QUARTER -> {
double y = v.getY();
v.setY(v.getX());
v.setX(-y);
}
case MODE_ANGLE -> {
double x = zCos * v.getX() + zSin * v.getY();
double y = -zSin * v.getX() + zCos * v.getY();
v.setX(x);
v.setY(y);
}
default -> {
}
}
switch (xMode) {
case MODE_FLIP -> {
v.setZ(-v.getZ());
v.setY(-v.getY());
}
case MODE_QUARTER -> {
double z = v.getZ();
v.setZ(-v.getY());
v.setY(z);
}
case MODE_THREE_QUARTER -> {
double z = v.getZ();
v.setZ(v.getY());
v.setY(-z);
}
case MODE_ANGLE -> {
double y = xCos * v.getY() + xSin * v.getZ();
double z = -xSin * v.getY() + xCos * v.getZ();
v.setY(y);
v.setZ(z);
}
default -> {
}
}
}
} }
@@ -6,12 +6,15 @@ import art.arcane.iris.pack.datapack.export.TemplateBlock;
import art.arcane.iris.pack.datapack.export.TemplateVolume; import art.arcane.iris.pack.datapack.export.TemplateVolume;
import art.arcane.iris.pack.datapack.export.VanillaBlockState; import art.arcane.iris.pack.datapack.export.VanillaBlockState;
import art.arcane.iris.pack.datapack.export.compile.WeightedState; import art.arcane.iris.pack.datapack.export.compile.WeightedState;
import art.arcane.volmlib.util.nbt.tag.CompoundTag;
import org.junit.Test; import org.junit.Test;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertArrayEquals;
@@ -22,6 +25,8 @@ import static org.junit.Assert.assertTrue;
public class ObjectVariantBakerTest { public class ObjectVariantBakerTest {
private static final String NAMESPACE = "iris_export"; private static final String NAMESPACE = "iris_export";
private static final String LEAVES = "minecraft:oak_leaves[distance=1,persistent=true,waterlogged=false]";
private static final long RIGHT_ANGLE_GOLDEN = 0x5db739a5b940bf1dL;
@Test @Test
public void variantBakingIsDeterministic() throws IOException { public void variantBakingIsDeterministic() throws IOException {
@@ -245,6 +250,171 @@ public class ObjectVariantBakerTest {
assertEquals(2, lifted.roots().reach()); assertEquals(2, lifted.roots().reach());
} }
@Test
public void diagonalTurnsFillSolidCanopiesWithoutHoles() {
for (double degrees : new double[]{45, 135}) {
assertSolid(leafDisc(6, 3), degrees, (x, z) -> x * x + z * z <= 25D);
assertSolid(leafBox(4, 3), degrees, (x, z) -> Math.abs(x) <= 3.75D && Math.abs(z) <= 3.75D);
}
}
@Test
public void diagonalTurnsKeepEveryThinTrunkColumn() {
int[][] columns = {{2, 0}, {0, 2}, {-2, 0}, {3, 1}};
List<TemplateBlock> blocks = new ArrayList<>();
for (int[] column : columns) {
for (int y = 0; y < 6; y++) {
blocks.add(block(column[0] + 3, y, column[1] + 3, "minecraft:oak_log[axis=y]"));
}
}
TemplateVolume trunks = new TemplateVolume(7, 6, 7, blocks);
for (double degrees : new double[]{45, 135, 225, 315}) {
BakedVariant variant = turned(trunks, new VariantSpec.Rotation(0, degrees, 0));
Map<Long, int[]> heights = new HashMap<>();
for (TemplateBlock block : variant.volume().blocks()) {
int x = anchoredX(variant, block);
int z = anchoredZ(variant, block);
heights.computeIfAbsent(cell(x, 0, z), ignored -> new int[]{x, z, 0})[2]++;
}
double cos = Math.cos(Math.toRadians(degrees));
double sin = Math.sin(Math.toRadians(degrees));
for (int[] column : columns) {
long forward = cell((int) Math.round(cos * column[0] + sin * column[1]), 0,
(int) Math.round(-sin * column[0] + cos * column[1]));
boolean kept = false;
for (Map.Entry<Long, int[]> output : heights.entrySet()) {
int[] value = output.getValue();
assertEquals(degrees + " degrees cut a trunk column short", 6, value[2]);
boolean samples = (int) Math.round(cos * value[0] - sin * value[1]) == column[0]
&& (int) Math.round(sin * value[0] + cos * value[1]) == column[1];
kept |= samples || output.getKey() == forward;
}
assertTrue(degrees + " degrees lost the trunk column at " + column[0] + "," + column[1], kept);
}
}
}
@Test
public void rightAngleTurnsKeepTheirExactLayout() {
CompoundTag chest = new CompoundTag();
chest.putString("id", "minecraft:chest");
TemplateVolume volume = new TemplateVolume(4, 4, 4, List.of(
block(0, 0, 0, "minecraft:stone"),
block(3, 0, 1, "minecraft:stone"),
block(1, 1, 2, "minecraft:oak_log[axis=x]"),
block(2, 2, 0, "minecraft:oak_stairs[facing=north,half=bottom,shape=outer_left,waterlogged=false]"),
new TemplateBlock(3, 1, 3, VanillaBlockState.parse("minecraft:chest[facing=east,type=single,waterlogged=false]"), chest),
block(1, 3, 1, "minecraft:oak_leaves[distance=1,persistent=false,waterlogged=false]"),
block(0, 2, 3, "minecraft:oak_slab[type=bottom,waterlogged=false]")));
TreeRoots roots = new TreeRoots(List.of(block(0, 0, 0, "minecraft:stone")), 2);
StringBuilder layout = new StringBuilder();
for (double scale : new double[]{1, 1.5}) {
for (int x = 0; x < 360; x += 90) {
for (int y = 0; y < 360; y += 90) {
for (int z = 0; z < 360; z += 90) {
VariantSpec spec = VariantSpec.builder().scale(scale).rotation(new VariantSpec.Rotation(x, y, z)).build();
describe(layout, new ObjectVariantBaker().bake("iris_export:object/turn/a", new ObjectSource(volume, -1, roots), spec));
}
}
}
}
assertEquals(RIGHT_ANGLE_GOLDEN, fnv(layout));
}
private static void assertSolid(TemplateVolume source, double degrees, Interior interior) {
BakedVariant variant = turned(source, new VariantSpec.Rotation(0, degrees, 0));
Set<Long> covered = new HashSet<>();
for (TemplateBlock block : variant.volume().blocks()) {
assertTrue(degrees + " degrees wrote a cell twice",
covered.add(cell(anchoredX(variant, block), anchoredY(variant, block), anchoredZ(variant, block))));
}
double cos = Math.cos(Math.toRadians(degrees));
double sin = Math.sin(Math.toRadians(degrees));
int reach = Math.max(source.sizeX(), source.sizeZ());
for (int y = 0; y < source.sizeY(); y++) {
for (int x = -reach; x <= reach; x++) {
for (int z = -reach; z <= reach; z++) {
if (interior.contains(cos * x - sin * z, sin * x + cos * z)) {
assertTrue(degrees + " degrees left a hole at " + x + "," + y + "," + z, covered.contains(cell(x, y, z)));
}
}
}
}
}
private static BakedVariant turned(TemplateVolume source, VariantSpec.Rotation rotation) {
return new ObjectVariantBaker().bake("iris_export:object/turn/b", new ObjectSource(source, 0, TreeRoots.NONE),
VariantSpec.builder().rotation(rotation).build());
}
private static int anchoredX(BakedVariant variant, TemplateBlock block) {
return block.x() - (variant.volume().sizeX() - 1) / 2;
}
private static int anchoredY(BakedVariant variant, TemplateBlock block) {
return block.y() + variant.baseY();
}
private static int anchoredZ(BakedVariant variant, TemplateBlock block) {
return block.z() - (variant.volume().sizeZ() - 1) / 2;
}
private static TemplateVolume leafDisc(int radius, int height) {
List<TemplateBlock> blocks = new ArrayList<>();
for (int y = 0; y < height; y++) {
for (int x = -radius; x <= radius; x++) {
for (int z = -radius; z <= radius; z++) {
if (x * x + z * z <= radius * radius) {
blocks.add(block(x + radius, y, z + radius, LEAVES));
}
}
}
}
return new TemplateVolume(radius * 2 + 1, height, radius * 2 + 1, blocks);
}
private static TemplateVolume leafBox(int half, int height) {
List<TemplateBlock> blocks = new ArrayList<>();
for (int y = 0; y < height; y++) {
for (int x = -half; x <= half; x++) {
for (int z = -half; z <= half; z++) {
blocks.add(block(x + half, y, z + half, LEAVES));
}
}
}
return new TemplateVolume(half * 2 + 1, height, half * 2 + 1, blocks);
}
private static void describe(StringBuilder layout, BakedVariant variant) {
TemplateVolume volume = variant.volume();
layout.append(variant.id()).append(' ').append(volume.sizeX()).append('x').append(volume.sizeY()).append('x')
.append(volume.sizeZ()).append(" base=").append(variant.baseY()).append('\n');
for (TemplateBlock block : volume.blocks()) {
layout.append(block.x()).append(',').append(block.y()).append(',').append(block.z()).append(' ')
.append(block.state().canonical()).append(' ')
.append(block.nbt() == null ? "-" : block.nbt().getString("id")).append('\n');
}
layout.append("roots reach=").append(variant.roots().reach()).append('\n');
for (TemplateBlock cell : variant.roots().cells()) {
layout.append(cell.x()).append(',').append(cell.y()).append(',').append(cell.z()).append(' ')
.append(cell.state().canonical()).append('\n');
}
}
private static long fnv(CharSequence text) {
long hash = 0xcbf29ce484222325L;
for (int index = 0; index < text.length(); index++) {
hash ^= text.charAt(index);
hash *= 0x100000001b3L;
}
return hash;
}
private static long cell(int x, int y, int z) {
return (((long) x & 0x1FFFFFL) << 42) | (((long) y & 0x1FFFFFL) << 21) | ((long) z & 0x1FFFFFL);
}
private static TemplateVolume tree() { private static TemplateVolume tree() {
List<TemplateBlock> blocks = new ArrayList<>(); List<TemplateBlock> blocks = new ArrayList<>();
for (int y = 0; y < 5; y++) { for (int y = 0; y < 5; y++) {
@@ -271,4 +441,8 @@ public class ObjectVariantBakerTest {
} }
return states; return states;
} }
private interface Interior {
boolean contains(double x, double z);
}
} }
@@ -56,66 +56,6 @@ public class IrisObjectTransformsTileTest {
IrisPlatforms.unbind(); IrisPlatforms.unbind();
} }
@Test
public void rotationOmitsUnsupportedBlocksAndTheirTiles() {
IrisObject source = new IrisObject(3, 1, 1);
NativeBlockState unsupported = state("minecraft:wall_torch");
source.setUnsigned(0, 0, 0, unsupported);
source.setUnsignedTile(0, 0, 0, tile("minecraft:wall_torch", "omitted"));
source.setUnsigned(1, 0, 0, state("minecraft:chest"));
source.setUnsignedTile(1, 0, 0, tile("minecraft:chest", "kept"));
source.setUnsigned(2, 0, 0, state("minecraft:stone"));
IrisObjectRotation.StateRotator previous = IrisObjectRotation.bindPlatformRotator(
(rotation, block, x, y, z) -> block == unsupported ? null : block);
IrisObject rotated;
try {
rotated = source.rotateCopy(IrisObjectRotation.of(0, 90, 0));
} finally {
IrisObjectRotation.restorePlatformRotator(previous);
}
assertEquals(2, rotated.getBlocks().size());
assertEquals(1, rotated.getStates().size());
for (Map.Entry<IrisBlockVector, TileData> entry : rotated.getStates()) {
assertEquals("minecraft:chest", rotated.getBlocks().get(entry.getKey()).materialKey());
assertEquals("kept", entry.getValue().getProperties().get("name"));
}
assertEquals(3, source.getBlocks().size());
assertEquals(2, source.getStates().size());
}
@Test
public void fractionalRotationKeepsTileDataOnlyForTheWinningBlock() {
IrisObject source = new IrisObject(9, 1, 9);
for (int x = 0; x < 9; x++) {
for (int z = 0; z < 9; z++) {
boolean chest = (x + z) % 2 == 0;
source.setUnsigned(x, 0, z, state(chest ? "minecraft:chest" : "minecraft:stone"));
if (chest) {
source.setUnsignedTile(x, 0, z, tile("minecraft:chest", x + ":" + z));
}
}
}
IrisObjectRotation.StateRotator previous = IrisObjectRotation.bindPlatformRotator(
(rotation, block, x, y, z) -> block);
IrisObject rotated;
try {
rotated = source.rotateCopy(IrisObjectRotation.of(0, 45, 0));
} finally {
IrisObjectRotation.restorePlatformRotator(previous);
}
assertTrue(rotated.getBlocks().size() < source.getBlocks().size());
for (Map.Entry<IrisBlockVector, TileData> entry : rotated.getStates()) {
assertEquals("minecraft:chest", rotated.getBlocks().get(entry.getKey()).materialKey());
}
for (Map.Entry<IrisBlockVector, NativeBlockState> entry : rotated.getBlocks()) {
if (entry.getValue().materialKey().equals("minecraft:chest")) {
assertNotNull(rotated.getStates().get(entry.getKey()));
}
}
}
@Test @Test
public void scalingWaitsForMatchingGeometryAndVolume() throws Exception { public void scalingWaitsForMatchingGeometryAndVolume() throws Exception {
assertConsistentScaling(false); assertConsistentScaling(false);
@@ -1,11 +1,23 @@
package art.arcane.iris.structure.object; package art.arcane.iris.structure.object;
import art.arcane.iris.generation.geometry.IrisBlockVector; import art.arcane.iris.generation.geometry.IrisBlockVector;
import art.arcane.iris.pack.datapack.export.TemplateBlock;
import art.arcane.iris.pack.datapack.export.TemplateVolume;
import art.arcane.iris.pack.datapack.export.VanillaBlockState;
import art.arcane.iris.pack.datapack.export.compile.object.BakedVariant;
import art.arcane.iris.pack.datapack.export.compile.object.ObjectSource;
import art.arcane.iris.pack.datapack.export.compile.object.ObjectVariantBaker;
import art.arcane.iris.pack.datapack.export.compile.object.TreeRoots;
import art.arcane.iris.pack.datapack.export.compile.object.VariantSpec;
import art.arcane.iris.testsupport.KeyedBlockState; import art.arcane.iris.testsupport.KeyedBlockState;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import org.junit.Test; import org.junit.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Random; import java.util.Random;
import java.util.Set; import java.util.Set;
@@ -91,7 +103,11 @@ public class ObjectPlacementBakeTest {
distinct.add(ObjectPlacementBake.Key.of(spin, false, translate)); distinct.add(ObjectPlacementBake.Key.of(spin, false, translate));
assertSame(first.palette(), bake.palette()); assertSame(first.palette(), bake.palette());
assertTrue(object.placementBakes.size() <= 32); assertTrue(object.placementBakes.size() <= 32);
assertTransformed(object, spin, translate, bake); if (spin.axisAligned) {
assertTransformed(object, spin, false, translate, bake);
} else {
assertResampled(object, spin, translate, bake);
}
if (placement == 2000) { if (placement == 2000) {
settled = new HashMap<>(object.placementBakes); settled = new HashMap<>(object.placementBakes);
} }
@@ -104,13 +120,240 @@ public class ObjectPlacementBakeTest {
} }
} }
private static void assertTransformed(IrisObject object, SpinKernel spin, IrisBlockVector translate, ObjectPlacementBake bake) { @Test
public void diagonalYawTurnsFillEverySolidInteriorCellExactlyOnce() {
IrisObject disc = leafDisc(6, 3);
IrisObject box = leafBox(4, 3);
IrisObjectRotation diagonal = yawRotation(45D);
for (int degrees : new int[]{45, 135}) {
SpinKernel spin = new SpinKernel(diagonal, 0, degrees, 0);
assertSolidCoverage(ObjectPlacementBake.of(disc, spin, false, null), degrees, 6, 3,
(x, z) -> x * x + z * z <= 25D);
assertSolidCoverage(ObjectPlacementBake.of(box, spin, false, null), degrees, 6, 3,
(x, z) -> Math.abs(x) <= 3.75D && Math.abs(z) <= 3.75D);
}
}
@Test
public void diagonalYawTurnsKeepEveryThinTrunkColumn() {
IrisObject object = new IrisObject(7, 6, 7);
KeyedBlockState log = new KeyedBlockState("minecraft:oak_log[axis=y]");
int[][] columns = {{2, 0}, {0, 2}, {-2, 0}, {3, 1}};
for (int[] column : columns) {
for (int y = 0; y < 6; y++) {
object.getBlocks().put(new IrisBlockVector(column[0], y, column[1]), log);
}
}
IrisObjectRotation diagonal = yawRotation(45D);
for (int degrees : new int[]{45, 135, 225, 315}) {
SpinKernel spin = new SpinKernel(diagonal, 0, degrees, 0);
ObjectPlacementBake bake = ObjectPlacementBake.of(object, spin, false, null);
Set<Long> covered = new HashSet<>();
Set<Long> sampled = new HashSet<>();
for (int index = 0; index < bake.count(); index++) {
assertTrue(covered.add(cell(bake.offsetX(index), bake.offsetY(index), bake.offsetZ(index))));
IrisBlockVector inverse = new IrisBlockVector(bake.offsetX(index), bake.offsetY(index), bake.offsetZ(index));
spin.inverseRotate(inverse);
sampled.add(cell((int) Math.round(inverse.getX()), (int) Math.round(inverse.getY()), (int) Math.round(inverse.getZ())));
}
for (IrisBlockVector source : object.getBlocks().keys()) {
IrisBlockVector forward = source.clone();
spin.rotate(forward);
boolean represented = sampled.contains(cell(source.getBlockX(), source.getBlockY(), source.getBlockZ()))
|| covered.contains(cell((int) Math.round(forward.getX()), (int) Math.round(forward.getY()),
(int) Math.round(forward.getZ())));
assertTrue(degrees + " degrees dropped trunk block " + source, represented);
}
}
}
@Test
public void quarterTurnsKeepTheForwardTransform() {
IrisObject object = leafDisc(6, 3);
object.getBlocks().put(new IrisBlockVector(7, 1, -2), new KeyedBlockState("minecraft:oak_log[axis=x]"));
IrisBlockVector translate = new IrisBlockVector(1.5D, -2D, 0.25D);
IrisObjectRotation[] rotations = {new IrisObjectRotation(), yawRotation(45D)};
for (IrisObjectRotation rotation : rotations) {
for (int degrees = 0; degrees < 360; degrees += 90) {
SpinKernel spin = new SpinKernel(rotation, 0, degrees, 0);
assertTransformed(object, spin, false, null, ObjectPlacementBake.of(object, spin, false, null));
assertTransformed(object, spin, true, translate, ObjectPlacementBake.of(object, spin, true, translate));
}
}
for (int degrees = 90; degrees < 360; degrees += 90) {
SpinKernel spin = new SpinKernel(IrisObjectRotation.of(0D, degrees, 0D), 0, 0, 0);
assertTransformed(object, spin, false, null, ObjectPlacementBake.of(object, spin, false, null));
}
}
@Test
public void datapackVariantsMatchPlacementAtNonRightAngles() {
IrisObject object = new IrisObject(13, 5, 13);
KeyedBlockState leaves = new KeyedBlockState("minecraft:oak_leaves[distance=1,persistent=true]");
for (int y = 0; y <= 2; y++) {
for (int x = -6; x <= 6; x++) {
for (int z = -6; z <= 6; z++) {
if (x * x + z * z <= 36) {
object.getBlocks().put(new IrisBlockVector(x, y, z), leaves);
}
}
}
}
for (int y = -2; y <= 1; y++) {
object.getBlocks().put(new IrisBlockVector(0, y, 0), new KeyedBlockState("minecraft:oak_log[axis=y]"));
object.getBlocks().put(new IrisBlockVector(4, y, -3), new KeyedBlockState("minecraft:birch_log[axis=y]"));
}
object.getBlocks().put(new IrisBlockVector(-5, 2, 2), new KeyedBlockState("minecraft:gold_block"));
object.getBlocks().put(new IrisBlockVector(2, -2, 5), new KeyedBlockState("minecraft:stone"));
List<TemplateBlock> blocks = new ArrayList<>();
for (IrisBlockVector key : object.getBlocks().keys()) {
blocks.add(new TemplateBlock(key.getBlockX() + object.getCenter().getX(), key.getBlockY() + object.getCenter().getY(),
key.getBlockZ() + object.getCenter().getZ(), VanillaBlockState.parse(object.getBlocks().get(key).key()), null));
}
ObjectSource source = new ObjectSource(new TemplateVolume(object.getW(), object.getH(), object.getD(), blocks), 0,
TreeRoots.NONE);
double[][] angles = {{0D, 45D, 0D}, {0D, 135D, 0D}, {0D, 30D, 0D}, {20D, 45D, 0D}, {0D, 60D, 15D}};
for (double[] angle : angles) {
IrisObjectRotation rotation = IrisObjectRotation.of(angle[0], angle[1], angle[2]);
SpinKernel spin = new SpinKernel(rotation, 0, 0, 0);
ObjectPlacementBake bake = ObjectPlacementBake.of(object, spin, false, null);
int lift = rotation.rotate(new IrisBlockVector(0, object.getCenter().getY(), 0), 0, 0, 0).getBlockY();
Set<String> placed = new HashSet<>();
for (int index = 0; index < bake.count(); index++) {
String key = bake.palette()[bake.paletteIndex(index)].key();
placed.add(bake.offsetX(index) + "," + (bake.offsetY(index) + lift) + "," + bake.offsetZ(index) + " "
+ key.substring(0, key.indexOf('[') < 0 ? key.length() : key.indexOf('[')));
}
BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/parity/a", source,
VariantSpec.builder().rotation(new VariantSpec.Rotation(angle[0], angle[1], angle[2])).build());
Set<String> exported = new HashSet<>();
TemplateVolume volume = variant.volume();
for (TemplateBlock block : volume.blocks()) {
exported.add((block.x() - (volume.sizeX() - 1) / 2) + "," + (block.y() + variant.baseY()) + ","
+ (block.z() - (volume.sizeZ() - 1) / 2) + " " + block.state().name());
}
assertEquals(Arrays.toString(angle), placed, exported);
}
}
private static void assertResampled(IrisObject object, SpinKernel spin, IrisBlockVector translate, ObjectPlacementBake bake) {
double[] exact = bake.exact(object, spin);
Set<Long> forward = new HashSet<>();
for (IrisBlockVector source : object.getBlocks().keys()) {
IrisBlockVector moved = source.clone();
spin.rotate(moved);
moved.add(translate);
forward.add(cell((int) Math.round(moved.getX()), (int) Math.round(moved.getY()), (int) Math.round(moved.getZ())));
}
Set<Long> covered = new HashSet<>();
Set<Long> sampled = new HashSet<>();
for (int index = 0; index < bake.count(); index++) {
long target = cell(bake.offsetX(index), bake.offsetY(index), bake.offsetZ(index));
assertTrue(covered.add(target));
IrisBlockVector inverse = new IrisBlockVector(bake.offsetX(index), bake.offsetY(index), bake.offsetZ(index));
inverse.subtract(translate);
spin.inverseRotate(inverse);
IrisBlockVector nearest = new IrisBlockVector((int) Math.round(inverse.getX()), (int) Math.round(inverse.getY()),
(int) Math.round(inverse.getZ()));
NativeBlockState state = bake.palette()[bake.paletteIndex(index)];
NativeBlockState nearestState = object.getBlocks().get(nearest);
if (nearestState != null) {
assertSame(nearestState, state);
sampled.add(cell(nearest.getBlockX(), nearest.getBlockY(), nearest.getBlockZ()));
} else {
assertTrue(forward.contains(target));
}
assertEquals(bake.offsetY(index), bake.layerY(index));
assertEquals(bake.offsetX(index), exact[index * 3], 0D);
assertEquals(bake.offsetY(index), exact[index * 3 + 1], 0D);
assertEquals(bake.offsetZ(index), exact[index * 3 + 2], 0D);
}
for (IrisBlockVector source : object.getBlocks().keys()) {
IrisBlockVector moved = source.clone();
spin.rotate(moved);
moved.add(translate);
assertTrue(sampled.contains(cell(source.getBlockX(), source.getBlockY(), source.getBlockZ()))
|| covered.contains(cell((int) Math.round(moved.getX()), (int) Math.round(moved.getY()),
(int) Math.round(moved.getZ()))));
}
}
private static void assertSolidCoverage(ObjectPlacementBake bake, int degrees, int radius, int height, Interior interior) {
Map<Long, Integer> covered = new HashMap<>();
for (int index = 0; index < bake.count(); index++) {
covered.merge(cell(bake.offsetX(index), bake.offsetY(index), bake.offsetZ(index)), 1, Integer::sum);
}
double cos = Math.cos(Math.toRadians(degrees));
double sin = Math.sin(Math.toRadians(degrees));
int reach = radius * 2;
for (int y = 0; y < height; y++) {
for (int x = -reach; x <= reach; x++) {
for (int z = -reach; z <= reach; z++) {
int writes = covered.getOrDefault(cell(x, y, z), 0);
assertTrue(degrees + " degrees wrote cell " + x + "," + y + "," + z + " " + writes + " times", writes <= 1);
if (interior.contains(cos * x - sin * z, sin * x + cos * z)) {
assertEquals(degrees + " degrees left a hole at " + x + "," + y + "," + z, 1, writes);
}
}
}
}
}
private static long cell(int x, int y, int z) {
return (((long) x & 0x1FFFFFL) << 42) | (((long) y & 0x1FFFFFL) << 21) | ((long) z & 0x1FFFFFL);
}
private static IrisObjectRotation yawRotation(double interval) {
IrisObjectRotation rotation = new IrisObjectRotation();
rotation.setYAxis(new IrisAxisRotationClamp(true, false, 0D, 0D, interval));
return rotation;
}
private static IrisObject leafDisc(int radius, int height) {
IrisObject object = new IrisObject(radius * 2 + 1, height, radius * 2 + 1);
KeyedBlockState leaves = new KeyedBlockState("minecraft:oak_leaves[distance=1,persistent=true]");
for (int y = 0; y < height; y++) {
for (int x = -radius; x <= radius; x++) {
for (int z = -radius; z <= radius; z++) {
if (x * x + z * z <= radius * radius) {
object.getBlocks().put(new IrisBlockVector(x, y, z), leaves);
}
}
}
}
return object;
}
private static IrisObject leafBox(int half, int height) {
IrisObject object = new IrisObject(half * 2 + 1, height, half * 2 + 1);
KeyedBlockState leaves = new KeyedBlockState("minecraft:oak_leaves[distance=1,persistent=true]");
for (int y = 0; y < height; y++) {
for (int x = -half; x <= half; x++) {
for (int z = -half; z <= half; z++) {
object.getBlocks().put(new IrisBlockVector(x, y, z), leaves);
}
}
}
return object;
}
private interface Interior {
boolean contains(double x, double z);
}
private static void assertTransformed(IrisObject object, SpinKernel spin, boolean ceilingHang, IrisBlockVector translate,
ObjectPlacementBake bake) {
double[] exact = bake.exact(object, spin); double[] exact = bake.exact(object, spin);
int index = 0; int index = 0;
for (IrisBlockVector source : object.getBlocks().keys()) { for (IrisBlockVector source : object.getBlocks().keys()) {
IrisBlockVector expected = source.clone(); IrisBlockVector expected = source.clone();
spin.rotate(expected); spin.rotate(expected);
expected.add(translate); if (ceilingHang) {
expected.setY(-expected.getBlockY());
}
if (translate != null) {
expected.add(translate);
}
assertEquals((int) Math.round(expected.getX()), bake.offsetX(index)); assertEquals((int) Math.round(expected.getX()), bake.offsetX(index));
assertEquals((int) Math.round(expected.getY()), bake.offsetY(index)); assertEquals((int) Math.round(expected.getY()), bake.offsetY(index));
assertEquals((int) Math.round(expected.getZ()), bake.offsetZ(index)); assertEquals((int) Math.round(expected.getZ()), bake.offsetZ(index));
@@ -63,9 +63,9 @@ public class ObjectPlacementGoldenTest {
private static final int OBJECTS = 200; private static final int OBJECTS = 200;
private static final int SEEDS = 5; private static final int SEEDS = 5;
private static final long GOLDEN = 0x0b870e7696a055b0L; private static final long GOLDEN = 0x930e130dc51b6f67L;
private static final long MARKER_GOLDEN = 0x60b4bbf1144b239cL; private static final long MARKER_GOLDEN = 0x67ae55d574c3894eL;
private static final long HIGH_VARIANT_GOLDEN = 0x998044b16f7f1f6fL; private static final long HIGH_VARIANT_GOLDEN = 0xdd61791b0f7e4d37L;
private static final List<String> PALETTE = List.of( private static final List<String> PALETTE = List.of(
"minecraft:stone", "minecraft:stone",
@@ -297,21 +297,6 @@ public class TreeGroundingPlacementTest {
} }
} }
@Test
public void rotatedCopiesKeepMetadataOnTheirActualRootBlocks() {
IrisObject source = tree(true);
Map<Position, NativeBlockState> original = snapshot(source);
TreeGrounding grounding = source.getTreeGrounding();
for (int angle : new int[]{45, 90, 180, 270}) {
IrisObject rotated = source.rotateCopy(IrisObjectRotation.of(0, angle, 0));
assertGroundingMatchesBlocks(rotated);
assertEquals(3, rotated.getTreeGrounding().maxExtension());
assertEquals(original, snapshot(source));
assertSame(grounding, source.getTreeGrounding());
}
}
@Test @Test
public void shrinkwrapMovesRootMetadataAndInvalidatesItsFootprint() { public void shrinkwrapMovesRootMetadataAndInvalidatesItsFootprint() {
IrisObject source = tree(true); IrisObject source = tree(true);
@@ -397,7 +382,6 @@ public class TreeGroundingPlacementTest {
IrisObject shrunk = source.copy(); IrisObject shrunk = source.copy();
shrunk.shrinkwrap(); shrunk.shrinkwrap();
for (IrisObject transformed : List.of(source.copy(), shrunk, for (IrisObject transformed : List.of(source.copy(), shrunk,
source.rotateCopy(IrisObjectRotation.of(0, 90, 0)),
source.scaled(2, IrisObjectPlacementScaleInterpolator.NONE), source.scaled(2, IrisObjectPlacementScaleInterpolator.NONE),
source.scaledAroundOrigin(2, IrisObjectPlacementScaleInterpolator.NONE))) { source.scaledAroundOrigin(2, IrisObjectPlacementScaleInterpolator.NONE))) {
assertGroundingMatchesBlocks(transformed); assertGroundingMatchesBlocks(transformed);