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
This commit is contained in:
Brian Neumann-Fopiano
2026-09-06 03:56:26 -04:00
parent 420148fcc7
commit ce431b524e
6 changed files with 186 additions and 9 deletions
@@ -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;
}
@@ -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);
}
}
@@ -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();
@@ -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);
}
}
}
@@ -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<Integer, Integer> tops = new HashMap<>();
Set<IrisBlockVector> 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<IrisBlockVector> 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));
}
}
@@ -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<PlatformBlockState> normal = new ArrayList<>();
List<PlatformBlockState> large = new ArrayList<>();
TreeBlockCanvas.Cell cell = new TreeBlockCanvas.Cell(
TreeBlockCanvas.Role.SECONDARY_LEAF, TreeBlockCanvas.Axis.NONE, false, -1, null);
try (MockedStatic<B> 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<PlatformBlockState> 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();