From ce431b524e5d8e324e61cb50a0c994597cbd083e Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 6 Sep 2026 03:56:26 -0400 Subject: [PATCH 1/2] Fixed featurette edge cases - Fan coral tips follow the existing top of each occupied column - Large secondary leaf weights preserve their authored proportions - Zero ore thresholds skip noise sampling and block generation - Regressions cover fan silhouettes, weighted leaf choices, and ore cutoffs --- .../iris/engine/object/IrisOreGenerator.java | 2 +- .../engine/object/coral/CoralGenerator.java | 10 +-- .../engine/object/tree/TreeBlockResolver.java | 9 +-- .../engine/object/IrisOreGeneratorTest.java | 66 +++++++++++++++++ .../object/coral/CoralGeneratorTest.java | 72 +++++++++++++++++++ .../object/tree/TreeBlockResolverTest.java | 36 ++++++++++ 6 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisOreGeneratorTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/coral/CoralGeneratorTest.java diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisOreGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisOreGenerator.java index f124c8e89..1bac17826 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisOreGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisOreGenerator.java @@ -58,7 +58,7 @@ public class IrisOreGenerator { } public PlatformBlockState generate(int x, int y, int z, RNG rng, IrisData data) { - if (!range.contains(y)) { + if (threshold <= 0 || !range.contains(y)) { return null; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/coral/CoralGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/coral/CoralGenerator.java index 26ea6a96e..e7f2822fa 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/coral/CoralGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/coral/CoralGenerator.java @@ -88,16 +88,18 @@ public final class CoralGenerator { private static void buildFan(CoralCanvas canvas, IrisCoral coral, int height, long seed) { int half = Math.max(1, coral.getFanWidth()); - for (int y = 0; y < height; y++) { + int tippedWidth = -1; + for (int y = height - 1; y >= 0; y--) { double t = y / (double) Math.max(1, height - 1); double profile = Math.sin(Math.PI * t); int w = (int) Math.round(half * profile); for (int dx = -w; dx <= w; dx++) { canvas.set(dx, y, 0, CoralCanvas.Role.STRUCTURE); + if (Math.abs(dx) > tippedWidth) { + placeTip(canvas, coral, dx, y, 0, seed + dx); + } } - } - for (int dx = -half; dx <= half; dx++) { - placeTip(canvas, coral, dx, height - 1, 0, seed + dx); + tippedWidth = Math.max(tippedWidth, w); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java index d869aac20..f0e23f988 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java @@ -23,6 +23,7 @@ import art.arcane.iris.engine.object.IrisMaterialPalette; import art.arcane.iris.engine.object.IrisProceduralBlocks; import art.arcane.iris.engine.object.IrisProceduralTree; import art.arcane.iris.engine.object.IrisTreeDecorator; +import art.arcane.iris.engine.object.IrisTreeSecondaryLeaf; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.math.RNG; @@ -106,16 +107,16 @@ public final class TreeBlockResolver { } private static String pickWeighted(IrisProceduralTree tree, RNG rng) { - int total = 0; - for (art.arcane.iris.engine.object.IrisTreeSecondaryLeaf s : tree.getWeightedSecondaryLeaves()) { + long total = 0; + for (IrisTreeSecondaryLeaf s : tree.getWeightedSecondaryLeaves()) { total += Math.max(0, s.getWeight()); } if (total <= 0) { return null; } double r = rng.nextDouble() * total; - double cumulative = 0; - for (art.arcane.iris.engine.object.IrisTreeSecondaryLeaf s : tree.getWeightedSecondaryLeaves()) { + long cumulative = 0; + for (IrisTreeSecondaryLeaf s : tree.getWeightedSecondaryLeaves()) { cumulative += Math.max(0, s.getWeight()); if (r < cumulative) { return s.getBlock(); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisOreGeneratorTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisOreGeneratorTest.java new file mode 100644 index 000000000..748f09560 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisOreGeneratorTest.java @@ -0,0 +1,66 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class IrisOreGeneratorTest { + @Test + public void zeroThresholdDisablesEvenZeroNoiseSamples() { + Fixture fixture = new Fixture(0.0); + fixture.generator.setThreshold(0.0); + + assertNull(fixture.generator.generate(2, 40, 3, fixture.rng, null)); + verifyNoInteractions(fixture.noise, fixture.style); + } + + @Test + public void positiveThresholdStillIncludesExactCutoffAndVerticalBounds() { + Fixture fixture = new Fixture(0.5); + fixture.generator.setThreshold(0.5); + + assertSame(fixture.ore, fixture.generator.generate(2, 30, 3, fixture.rng, null)); + assertSame(fixture.ore, fixture.generator.generate(2, 80, 3, fixture.rng, null)); + assertNull(fixture.generator.generate(2, 29, 3, fixture.rng, null)); + assertNull(fixture.generator.generate(2, 81, 3, fixture.rng, null)); + fixture.generator.setThreshold(0.49); + assertNull(fixture.generator.generate(2, 40, 3, fixture.rng, null)); + } + + @Test + public void fullThresholdIncludesMaximumNoise() { + Fixture fixture = new Fixture(1.0); + fixture.generator.setThreshold(1.0); + + assertSame(fixture.ore, fixture.generator.generate(2, 40, 3, fixture.rng, null)); + } + + private static final class Fixture { + private final RNG rng = new RNG(34L); + private final PlatformBlockState ore = mock(PlatformBlockState.class); + private final CNG noise = mock(CNG.class); + private final IrisGeneratorStyle style = mock(IrisGeneratorStyle.class); + private final IrisOreGenerator generator = new IrisOreGenerator(); + + private Fixture(double sample) { + IrisMaterialPalette palette = mock(IrisMaterialPalette.class); + when(palette.getPalette()).thenReturn(new KList<>(mock(IrisBlockData.class))); + when(palette.get(any(), anyDouble(), anyDouble(), anyDouble(), isNull())).thenReturn(ore); + when(noise.noise(anyDouble(), anyDouble(), anyDouble())).thenReturn(sample); + when(style.create(any(), isNull())).thenReturn(noise); + generator.setPalette(palette); + generator.setChanceStyle(style); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/coral/CoralGeneratorTest.java b/core/src/test/java/art/arcane/iris/engine/object/coral/CoralGeneratorTest.java new file mode 100644 index 000000000..69325e61a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/coral/CoralGeneratorTest.java @@ -0,0 +1,72 @@ +package art.arcane.iris.engine.object.coral; + +import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.engine.object.IrisCoral; +import art.arcane.iris.engine.object.IrisCoralForm; +import art.arcane.iris.engine.object.IrisMaterialPalette; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class CoralGeneratorTest { + @Test + public void fanTipsFollowTheTopOfEachOccupiedColumn() { + PlatformBlockState structure = mock(PlatformBlockState.class); + PlatformBlockState tip = mock(PlatformBlockState.class); + for (int height : new int[]{2, 7, 14}) { + for (int width : new int[]{1, 3, 11}) { + IrisCoral coral = new IrisCoral().setForm(IrisCoralForm.FAN) + .setHeightMin(height).setHeightMax(height).setFanWidth(width) + .setWaterlogged(false).setBlockPalette(palette(structure)); + IrisObject bare = CoralGenerator.generate(coral, 0, new RNG(0L), null); + Map tops = new HashMap<>(); + Set barePositions = new HashSet<>(); + for (IrisBlockVector position : bare.getBlocks().keys()) { + barePositions.add(position); + tops.merge(position.getBlockX(), position.getBlockY(), Math::max); + } + coral.setTipPalette(palette(tip)); + for (double chance : new double[]{0.0, 0.35, 1.0}) { + coral.setTipChance(chance); + for (long seed = 0; seed < 16; seed++) { + IrisObject decorated = CoralGenerator.generate(coral, 0, new RNG(seed), null); + Set positions = new HashSet<>(); + int tipCount = 0; + for (IrisBlockVector position : decorated.getBlocks().keys()) { + positions.add(position); + if (decorated.getBlocks().get(position) == tip) { + assertTrue("Fan tip must occupy an existing column", tops.containsKey(position.getBlockX())); + assertEquals(tops.get(position.getBlockX()).intValue(), position.getBlockY()); + tipCount++; + } + } + assertEquals(barePositions, positions); + if (chance == 1.0) { + assertEquals(tops.size(), tipCount); + } + } + } + } + } + } + + private static IrisMaterialPalette palette(PlatformBlockState state) { + IrisBlockData block = mock(IrisBlockData.class); + when(block.getWeight()).thenReturn(1); + when(block.getBlockData(null)).thenReturn(state); + return new IrisMaterialPalette().setPalette(new KList<>(block)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/tree/TreeBlockResolverTest.java b/core/src/test/java/art/arcane/iris/engine/object/tree/TreeBlockResolverTest.java index 52fdd22b7..5fa5550a2 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/tree/TreeBlockResolverTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/tree/TreeBlockResolverTest.java @@ -3,10 +3,13 @@ package art.arcane.iris.engine.object.tree; import art.arcane.iris.engine.object.IrisBlockData; import art.arcane.iris.engine.object.IrisMaterialPalette; import art.arcane.iris.engine.object.IrisProceduralTree; +import art.arcane.iris.engine.object.IrisTreeSecondaryLeaf; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import org.junit.Test; +import org.mockito.MockedStatic; import java.util.ArrayList; import java.util.HashSet; @@ -16,9 +19,42 @@ import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; public class TreeBlockResolverTest { + @Test + public void largeSecondaryWeightsKeepTheirRelativeDistribution() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setSeed(91874L); + tree.setLeavesPalette(palette("oak_leaves", "birch_leaves")); + PlatformBlockState blossom = mock(PlatformBlockState.class); + PlatformBlockState light = mock(PlatformBlockState.class); + List normal = new ArrayList<>(); + List large = new ArrayList<>(); + TreeBlockCanvas.Cell cell = new TreeBlockCanvas.Cell( + TreeBlockCanvas.Role.SECONDARY_LEAF, TreeBlockCanvas.Axis.NONE, false, -1, null); + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getStateOrNull("minecraft:cherry_leaves", false)).thenReturn(blossom); + blocks.when(() -> B.getStateOrNull("minecraft:shroomlight", false)).thenReturn(light); + for (int weight : new int[]{1, Integer.MAX_VALUE}) { + tree.setWeightedSecondaryLeaves(new KList<>( + new IrisTreeSecondaryLeaf("minecraft:cherry_leaves", weight), + new IrisTreeSecondaryLeaf("minecraft:shroomlight", weight))); + List resolved = weight == 1 ? normal : large; + for (int x = -16; x <= 16; x++) { + for (int y = 0; y < 16; y++) { + resolved.add(TreeBlockResolver.resolve(tree, null, cell, + new TreeBlockCanvas.Vec(x, y, x + y), new RNG(tree.getSeed()))); + } + } + } + } + assertTrue(normal.contains(blossom)); + assertTrue(normal.contains(light)); + assertEquals(normal, large); + } + @Test public void sharedPaletteRandomSourceMatchesFreshPerVoxelSources() { IrisProceduralTree tree = new IrisProceduralTree(); From 0efe919ff48ff5617b79790d1aedb733ad7abfd9 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 6 Sep 2026 03:56:40 -0400 Subject: [PATCH 2/2] Reused formation palette sources - Formation resolution shares one base palette source per object - Strata reuse one seeded source per occupied band - Mixed palette tests preserve block choices across negative bands and reversed traversal --- .../formation/FormationBlockResolver.java | 33 +++++--- .../object/formation/FormationGenerator.java | 3 +- .../formation/FormationBlockResolverTest.java | 79 +++++++++++++++++++ 3 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/engine/object/formation/FormationBlockResolverTest.java diff --git a/core/src/main/java/art/arcane/iris/engine/object/formation/FormationBlockResolver.java b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationBlockResolver.java index b801ef582..de02aeec8 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/formation/FormationBlockResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationBlockResolver.java @@ -25,35 +25,48 @@ import art.arcane.iris.engine.object.IrisProceduralBlocks; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.math.Vector3i; import art.arcane.volmlib.util.math.RNG; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; public final class FormationBlockResolver { - private FormationBlockResolver() { + private final IrisFormation formation; + private final IrisData data; + private final RNG paletteRng; + private final Int2ObjectOpenHashMap strataRngs = new Int2ObjectOpenHashMap<>(); + + public FormationBlockResolver(IrisFormation formation, IrisData data) { + this.formation = formation; + this.data = data; + paletteRng = new RNG(formation.getSeed()); } - public static PlatformBlockState resolve(IrisFormation f, IrisData data, FormationCanvas.Role role, Vector3i raw) { + public PlatformBlockState resolve(FormationCanvas.Role role, Vector3i raw) { int x = raw.getBlockX(); int y = raw.getBlockY(); int z = raw.getBlockZ(); - RNG paletteRng = new RNG(f.getSeed()); - if (role == FormationCanvas.Role.CAP && capDefined(f)) { - PlatformBlockState cap = IrisProceduralBlocks.resolve(f.getCapBlock(), f.getCapPalette(), data, x, y, z, paletteRng); + if (role == FormationCanvas.Role.CAP && capDefined(formation)) { + PlatformBlockState cap = IrisProceduralBlocks.resolve(formation.getCapBlock(), formation.getCapPalette(), data, x, y, z, paletteRng); if (cap != null) { return cap; } } - if (strataDefined(f)) { - IrisMaterialPalette strata = f.getStrataPalette(); - int thickness = Math.max(1, f.getStrataThickness()); + if (strataDefined(formation)) { + IrisMaterialPalette strata = formation.getStrataPalette(); + int thickness = Math.max(1, formation.getStrataThickness()); int band = Math.floorDiv(y, thickness); - PlatformBlockState strataState = strata.get(new RNG(f.getSeed() + (band * 31L)), x, band, z, data); + RNG strataRng = strataRngs.get(band); + if (strataRng == null) { + strataRng = new RNG(formation.getSeed() + band * 31L); + strataRngs.put(band, strataRng); + } + PlatformBlockState strataState = strata.get(strataRng, x, band, z, data); if (strataState != null) { return strataState; } } - return IrisProceduralBlocks.resolve(f.getBlock(), f.getBlockPalette(), data, x, y, z, paletteRng); + return IrisProceduralBlocks.resolve(formation.getBlock(), formation.getBlockPalette(), data, x, y, z, paletteRng); } private static boolean capDefined(IrisFormation f) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/formation/FormationGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationGenerator.java index e6fe4e577..60ae3377f 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/formation/FormationGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationGenerator.java @@ -62,8 +62,9 @@ public final class FormationGenerator { } Map resolved = new HashMap<>(); + FormationBlockResolver resolver = new FormationBlockResolver(f, data); for (Map.Entry entry : canvas.getCells().entrySet()) { - PlatformBlockState bd = FormationBlockResolver.resolve(f, data, entry.getValue(), entry.getKey()); + PlatformBlockState bd = resolver.resolve(entry.getValue(), entry.getKey()); if (bd == null) { continue; } diff --git a/core/src/test/java/art/arcane/iris/engine/object/formation/FormationBlockResolverTest.java b/core/src/test/java/art/arcane/iris/engine/object/formation/FormationBlockResolverTest.java new file mode 100644 index 000000000..d5f535eed --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/formation/FormationBlockResolverTest.java @@ -0,0 +1,79 @@ +package art.arcane.iris.engine.object.formation; + +import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.engine.object.IrisFormation; +import art.arcane.iris.engine.object.IrisMaterialPalette; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class FormationBlockResolverTest { + @Test + public void sharedResolverMatchesFreshPaletteSeedsAcrossBands() { + IrisFormation formation = new IrisFormation().setSeed(43191L).setStrataThickness(3); + IrisMaterialPalette body = palette("stone", "andesite"); + IrisMaterialPalette cap = palette("granite", "diorite"); + IrisMaterialPalette strata = palette("terracotta", "red_terracotta"); + formation.setBlockPalette(body); + List positions = new ArrayList<>(); + for (int x = -8; x <= 8; x++) { + for (int y = -12; y <= 24; y++) { + positions.add(new Vector3i(x, y, x - y)); + } + } + Set materials = new HashSet<>(); + for (boolean withStrata : new boolean[]{false, true}) { + formation.setStrataPalette(withStrata ? strata : null); + for (boolean withCap : new boolean[]{false, true}) { + formation.setCapPalette(withCap ? cap : null); + FormationBlockResolver resolver = new FormationBlockResolver(formation, null); + for (int pass = 0; pass < 2; pass++) { + for (Vector3i position : positions) { + FormationCanvas.Role role = position.getBlockX() % 2 == 0 + ? FormationCanvas.Role.CAP : FormationCanvas.Role.BODY; + IrisMaterialPalette expectedPalette = withCap && role == FormationCanvas.Role.CAP + ? cap : withStrata ? strata : body; + int sampleY = expectedPalette == strata + ? Math.floorDiv(position.getBlockY(), formation.getStrataThickness()) : position.getBlockY(); + long sampleSeed = formation.getSeed() + (expectedPalette == strata ? sampleY * 31L : 0L); + RNG fresh = new RNG(sampleSeed); + PlatformBlockState expected = expectedPalette.get(fresh, + position.getBlockX(), sampleY, position.getBlockZ(), null); + assertSame(expected, resolver.resolve(role, position)); + assertEquals(new RNG(sampleSeed).nextLong(), fresh.nextLong()); + materials.add(expected.key()); + } + Collections.reverse(positions); + } + } + } + assertTrue(materials.size() == 6); + } + + private static IrisMaterialPalette palette(String first, String second) { + return new IrisMaterialPalette().setPalette(new KList<>(block(first), block(second))); + } + + private static IrisBlockData block(String material) { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn("minecraft:" + material); + IrisBlockData block = mock(IrisBlockData.class); + when(block.getWeight()).thenReturn(1); + when(block.getBlockData(null)).thenReturn(state); + return block; + } +}