From b0860f94fcaf3692823ab12133447522d4cab023 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sat, 31 Oct 2026 19:23:25 -0400 Subject: [PATCH] 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 --- .../compile/object/ObjectVariantBaker.java | 47 +++- .../export/compile/object/VariantSpec.java | 4 + .../iris/structure/object/IrisObject.java | 4 - .../structure/object/IrisObjectRotation.java | 8 - .../object/IrisObjectTransforms.java | 62 +---- .../object/ObjectMarkerPlacement.java | 8 +- .../structure/object/ObjectPlacementBake.java | 134 ++++++++-- .../structure/object/RotationResampler.java | 205 ++++++++++++++ .../iris/structure/object/SpinKernel.java | 86 ++++++ .../object/ObjectVariantBakerTest.java | 174 ++++++++++++ .../object/IrisObjectTransformsTileTest.java | 60 ----- .../object/ObjectPlacementBakeTest.java | 249 +++++++++++++++++- .../object/ObjectPlacementGoldenTest.java | 6 +- .../object/TreeGroundingPlacementTest.java | 16 -- 14 files changed, 874 insertions(+), 189 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/structure/object/RotationResampler.java diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBaker.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBaker.java index 708445736..a87e48b20 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBaker.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBaker.java @@ -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.VanillaResourceIdentifier; 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 java.util.ArrayList; @@ -16,6 +17,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; +import java.util.function.ToIntFunction; public final class ObjectVariantBaker { private static final Set AIR = Set.of("minecraft:air", "minecraft:cave_air", "minecraft:void_air"); @@ -35,6 +37,10 @@ public final class ObjectVariantBaker { .comparingInt(Voxel::y) .thenComparingInt(Voxel::x) .thenComparingInt(Voxel::z); + private static final Comparator PLACEMENT_ORDER = Comparator + .comparingInt(Voxel::x) + .thenComparingInt(Voxel::y) + .thenComparingInt(Voxel::z); public static String templateId(String namespace, String objectPath, VariantSpec spec, DataVersion target) { VanillaResourceIdentifier.requireNamespace(namespace, "template namespace"); @@ -59,7 +65,7 @@ public final class ObjectVariantBaker { sizeZ = scaledSize(sizeZ, spec.scale()); 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) { voxels = warp(voxels, spec.warp()); } @@ -243,11 +249,19 @@ public final class ObjectVariantBaker { return new ArrayList<>(scaled.values()); } - private static List anchor(List voxels, VariantSpec.Rotation rotation, int centerX, int centerY, int centerZ) { + private static List anchor(String templateId, List voxels, VariantSpec.Rotation rotation, int centerX, + int centerY, int centerZ) { BlockStateRotation states = BlockStateRotation.of(rotation.x(), rotation.y(), rotation.z()); double[] lift = rotate(0, centerY, 0, rotation); int rotatedCenterY = (int) Math.floor(lift[1]); List 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); Map anchored = new LinkedHashMap<>(); for (Voxel voxel : sorted) { @@ -260,6 +274,35 @@ public final class ObjectVariantBaker { return new ArrayList<>(anchored.values()); } + private static int[] offsets(List voxels, ToIntFunction 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 resampled(List 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 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) { double rx = x; double ry = y; diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/VariantSpec.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/VariantSpec.java index 5bc3c6a02..dbb29ee9d 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/VariantSpec.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/VariantSpec.java @@ -44,6 +44,10 @@ public record VariantSpec(double scale, Rotation rotation, VariantWarp warp, dou public boolean none() { 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 { diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java index 0c7b25738..47bb3f35f 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java @@ -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) { return IrisObjectTransforms.scaled(this, scale, interpolation); } diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectRotation.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectRotation.java index 1383ee813..a5365060f 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectRotation.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectRotation.java @@ -168,14 +168,6 @@ public class IrisObjectRotation { return getRotation(spin, zAxis); } - public IrisObject rotateCopy(IrisObject e) { - if (e == null) { - return null; - } - - return e.rotateCopy(this); - } - public IrisBlockVector rotate(IrisBlockVector direction) { return rotate(direction, 0, 0, 0); } diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java index 804f2e484..3c28e7a9e 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java @@ -43,72 +43,12 @@ import java.util.Map; 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 { 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 d = new VectorMap<>(); - VectorMap dx = new VectorMap<>(); - TreeGrounding grounding = self.getTreeGrounding(); - Set canopy = grounding == null ? null : new HashSet<>(); - - for (Map.Entry 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 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) { return scaled(self, scale, interpolation, ScaleOrigin.DEFAULT); } diff --git a/core/src/main/java/art/arcane/iris/structure/object/ObjectMarkerPlacement.java b/core/src/main/java/art/arcane/iris/structure/object/ObjectMarkerPlacement.java index a11bf20f4..0eb74dc50 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/ObjectMarkerPlacement.java +++ b/core/src/main/java/art/arcane/iris/structure/object/ObjectMarkerPlacement.java @@ -67,12 +67,12 @@ final class ObjectMarkerPlacement { } } - String[] resolved = new String[bake.count()]; + String[] bySource = new String[bake.sourceCount()]; VectorMap.Cursor cursor = blocks.cursor(); int index = 0; - while (index < resolved.length && cursor.next()) { - resolved[index++] = markers.get(cursor.key()); + while (index < bySource.length && cursor.next()) { + bySource[index++] = markers.get(cursor.key()); } - return resolved; + return bake.markers(bySource); } } diff --git a/core/src/main/java/art/arcane/iris/structure/object/ObjectPlacementBake.java b/core/src/main/java/art/arcane/iris/structure/object/ObjectPlacementBake.java index 1e86d069f..395bce1aa 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/ObjectPlacementBake.java +++ b/core/src/main/java/art/arcane/iris/structure/object/ObjectPlacementBake.java @@ -8,6 +8,7 @@ import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import it.unimi.dsi.fastutil.ints.IntArrayList; import java.util.ArrayList; +import java.util.Arrays; import java.util.IdentityHashMap; import java.util.List; import java.util.concurrent.ConcurrentHashMap; @@ -21,15 +22,25 @@ final class ObjectPlacementBake { private final int[] offsetY; private final int[] offsetZ; 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 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.key = key; - this.offsetX = offsetX; - this.offsetY = offsetY; - this.offsetZ = offsetZ; - this.layerY = layerY; + this.offsetX = cells.offsetX(); + this.offsetY = cells.offsetY(); + this.offsetZ = cells.offsetZ(); + 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) { @@ -42,7 +53,10 @@ final class ObjectPlacementBake { if (cached != null && cached.layout == layout) { 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) { variants.replace(key, cached, built); } 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, - IrisBlockVector translateOffset) { + private static Cells transformed(IrisObject object, Layout layout, SpinKernel spin, boolean ceilingHang, + IrisBlockVector translateOffset) { int count = layout.count; int[] x = new int[count]; int[] y = new int[count]; @@ -66,7 +80,7 @@ final class ObjectPlacementBake { VectorMap.Cursor cursor = object.blocks.cursor(); int index = 0; 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 floorY = rotated.getBlockY(); x[index] = (int) Math.round(rotated.getX()); @@ -79,24 +93,46 @@ final class ObjectPlacementBake { if (index != count) { 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, - 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 static Cells resampled(IrisObject object, Layout layout, SpinKernel spin, boolean ceilingHang, + IrisBlockVector translateOffset) { + RotationResampler.Targets targets = layout.resampler(object).resample(spin, ceilingHang, translateOffset); + return gather(layout, targets.x(), targets.y(), targets.z(), targets.sources()); + } + + private static Cells gather(Layout layout, int[] x, int[] y, int[] z, int[] sources) { + int count = sources.length; + short[] paletteIndexes = new short[count]; + long[] canopy = layout.canopy == null ? null : new long[(count + 63) >>> 6]; + IntArrayList tileBlocks = layout.tileBlocks == null ? null : new IntArrayList(); + List 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() { + return offsetX.length; + } + + int sourceCount() { return layout.count; } @@ -117,7 +153,7 @@ final class ObjectPlacementBake { } int paletteIndex(int index) { - return layout.paletteIndexes[index]; + return paletteIndexes[index]; } NativeBlockState[] palette() { @@ -125,32 +161,51 @@ final class ObjectPlacementBake { } int[] tileBlocks() { - return layout.tileBlocks; + return tileBlocks; } TileData[] tileValues() { - return layout.tileValues; + return tileValues; } boolean canopy(int index) { - long[] canopy = layout.canopy; 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[] resolved = exact; if (resolved != null) { return resolved; } + resolved = sources == null ? transformedExact(object, spin) : targetExact(); + exact = resolved; + return resolved; + } + + private double[] transformedExact(IrisObject object, SpinKernel spin) { int count = layout.count; object.readLock.lock(); try { - resolved = new double[count * 3]; + double[] resolved = new double[count * 3]; IrisBlockVector rotated = new IrisBlockVector(0, 0, 0); VectorMap.Cursor cursor = object.blocks.cursor(); int index = 0; 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 + 1] = rotated.getY(); resolved[index * 3 + 2] = rotated.getZ(); @@ -159,10 +214,19 @@ final class ObjectPlacementBake { if (index != count) { throw new IllegalStateException("Object " + object.getLoadKey() + " changed while its placement bake was read"); } + return resolved; } finally { 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; } @@ -176,6 +240,7 @@ final class ObjectPlacementBake { private final int[] tileBlocks; private final TileData[] tileValues; private final long[] canopy; + private volatile RotationResampler resampler; private Layout(long blocksRevision, long statesRevision, TreeGrounding grounding, int count, short[] paletteIndexes, NativeBlockState[] palette, int[] tileBlocks, TileData[] tileValues, long[] canopy) { @@ -270,6 +335,15 @@ final class ObjectPlacementBake { && object.states.modificationRevision() == statesRevision && 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, @@ -291,4 +365,8 @@ final class ObjectPlacementBake { 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) { + } } diff --git a/core/src/main/java/art/arcane/iris/structure/object/RotationResampler.java b/core/src/main/java/art/arcane/iris/structure/object/RotationResampler.java new file mode 100644 index 000000000..ce75efb40 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/RotationResampler.java @@ -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.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); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/object/SpinKernel.java b/core/src/main/java/art/arcane/iris/structure/object/SpinKernel.java index abaa1598c..fc5df6fb4 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/SpinKernel.java +++ b/core/src/main/java/art/arcane/iris/structure/object/SpinKernel.java @@ -17,8 +17,10 @@ final class SpinKernel { static final int MODE_QUARTER = 2; static final int MODE_THREE_QUARTER = 3; static final int MODE_ANGLE = 4; + private static final double AXIS_ALIGNED_EPSILON = 1.0E-9D; final boolean rotates; + final boolean axisAligned; final int xMode; final int yMode; final int zMode; @@ -44,6 +46,9 @@ final class SpinKernel { zSin = Math.sin(zAngle); yCos = Math.cos(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) { @@ -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 -> { + } + } + } } diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBakerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBakerTest.java index e552b7c80..57302eb03 100644 --- a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBakerTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBakerTest.java @@ -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.VanillaBlockState; import art.arcane.iris.pack.datapack.export.compile.WeightedState; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; import org.junit.Test; import java.io.IOException; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import static org.junit.Assert.assertArrayEquals; @@ -22,6 +25,8 @@ import static org.junit.Assert.assertTrue; public class ObjectVariantBakerTest { 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 public void variantBakingIsDeterministic() throws IOException { @@ -245,6 +250,171 @@ public class ObjectVariantBakerTest { 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 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 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 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 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 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 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() { List blocks = new ArrayList<>(); for (int y = 0; y < 5; y++) { @@ -271,4 +441,8 @@ public class ObjectVariantBakerTest { } return states; } + + private interface Interior { + boolean contains(double x, double z); + } } diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectTransformsTileTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectTransformsTileTest.java index 49557da01..8f867123e 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectTransformsTileTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectTransformsTileTest.java @@ -56,66 +56,6 @@ public class IrisObjectTransformsTileTest { 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 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 entry : rotated.getStates()) { - assertEquals("minecraft:chest", rotated.getBlocks().get(entry.getKey()).materialKey()); - } - for (Map.Entry entry : rotated.getBlocks()) { - if (entry.getValue().materialKey().equals("minecraft:chest")) { - assertNotNull(rotated.getStates().get(entry.getKey())); - } - } - } - @Test public void scalingWaitsForMatchingGeometryAndVolume() throws Exception { assertConsistentScaling(false); diff --git a/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementBakeTest.java b/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementBakeTest.java index c24aa64b0..b699be704 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementBakeTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementBakeTest.java @@ -1,11 +1,23 @@ package art.arcane.iris.structure.object; 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.volmlib.nativelib.terrain.NativeBlockState; import org.junit.Test; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Random; import java.util.Set; @@ -91,7 +103,11 @@ public class ObjectPlacementBakeTest { distinct.add(ObjectPlacementBake.Key.of(spin, false, translate)); assertSame(first.palette(), bake.palette()); 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) { 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 covered = new HashSet<>(); + Set 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 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 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 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 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 covered = new HashSet<>(); + Set 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 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); int index = 0; for (IrisBlockVector source : object.getBlocks().keys()) { IrisBlockVector expected = source.clone(); 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.getY()), bake.offsetY(index)); assertEquals((int) Math.round(expected.getZ()), bake.offsetZ(index)); diff --git a/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementGoldenTest.java b/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementGoldenTest.java index bd295541c..dd27603f3 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementGoldenTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/ObjectPlacementGoldenTest.java @@ -63,9 +63,9 @@ public class ObjectPlacementGoldenTest { private static final int OBJECTS = 200; private static final int SEEDS = 5; - private static final long GOLDEN = 0x0b870e7696a055b0L; - private static final long MARKER_GOLDEN = 0x60b4bbf1144b239cL; - private static final long HIGH_VARIANT_GOLDEN = 0x998044b16f7f1f6fL; + private static final long GOLDEN = 0x930e130dc51b6f67L; + private static final long MARKER_GOLDEN = 0x67ae55d574c3894eL; + private static final long HIGH_VARIANT_GOLDEN = 0xdd61791b0f7e4d37L; private static final List PALETTE = List.of( "minecraft:stone", diff --git a/core/src/test/java/art/arcane/iris/structure/object/TreeGroundingPlacementTest.java b/core/src/test/java/art/arcane/iris/structure/object/TreeGroundingPlacementTest.java index 965ba322d..9c3652e9c 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/TreeGroundingPlacementTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/TreeGroundingPlacementTest.java @@ -297,21 +297,6 @@ public class TreeGroundingPlacementTest { } } - @Test - public void rotatedCopiesKeepMetadataOnTheirActualRootBlocks() { - IrisObject source = tree(true); - Map 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 public void shrinkwrapMovesRootMetadataAndInvalidatesItsFootprint() { IrisObject source = tree(true); @@ -397,7 +382,6 @@ public class TreeGroundingPlacementTest { IrisObject shrunk = source.copy(); shrunk.shrinkwrap(); for (IrisObject transformed : List.of(source.copy(), shrunk, - source.rotateCopy(IrisObjectRotation.of(0, 90, 0)), source.scaled(2, IrisObjectPlacementScaleInterpolator.NONE), source.scaledAroundOrigin(2, IrisObjectPlacementScaleInterpolator.NONE))) { assertGroundingMatchesBlocks(transformed);