From 0ca33068f3cdebd173de5fed5455f4bb29a3191d Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 14 Oct 2026 22:49:45 -0400 Subject: [PATCH] Discounted occupied flower columns - Converted patch decorators also lose the share of surface columns that earlier surface patches in the declaring biomes already occupy, estimated from those patches' expected coverage in the catalog - Canopy and surface occupancy are combined into one vegetation occlusion estimate per decorated feature --- .../iris/pack/ingest/ModFeatureIngester.java | 6 +-- .../pack/ingest/PatchDecoratorConverter.java | 36 +++++++++----- ...Estimate.java => VegetationOcclusion.java} | 48 +++++++++++++------ .../ingest/FeatureConverterFormatTest.java | 14 +++--- .../pack/ingest/ModIngesterFeatureTest.java | 2 +- ...Test.java => VegetationOcclusionTest.java} | 30 +++++++++--- 6 files changed, 94 insertions(+), 42 deletions(-) rename core/src/main/java/art/arcane/iris/pack/ingest/{CanopyEstimate.java => VegetationOcclusion.java} (61%) rename core/src/test/java/art/arcane/iris/pack/ingest/{CanopyEstimateTest.java => VegetationOcclusionTest.java} (61%) diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/ModFeatureIngester.java b/core/src/main/java/art/arcane/iris/pack/ingest/ModFeatureIngester.java index 7ff8c5a9a..38adc3ce6 100644 --- a/core/src/main/java/art/arcane/iris/pack/ingest/ModFeatureIngester.java +++ b/core/src/main/java/art/arcane/iris/pack/ingest/ModFeatureIngester.java @@ -45,7 +45,7 @@ final class ModFeatureIngester { private final IngestPackModel model; private final WorldgenCatalogIndex index; private final TreeFeatures trees; - private final CanopyEstimate canopy; + private final VegetationOcclusion occlusion; private final Map plans = new TreeMap<>(); private final Set nativeKeys = new TreeSet<>(); private final Set vanillaScopedKeys = new TreeSet<>(); @@ -54,7 +54,7 @@ final class ModFeatureIngester { this.model = model; this.index = index; this.trees = new TreeFeatures(index); - this.canopy = new CanopyEstimate(index); + this.occlusion = new VegetationOcclusion(index); Map> declaring = new TreeMap<>(); Map> steps = new TreeMap<>(); for (CatalogBiome biome : index.catalog().biomes()) { @@ -210,7 +210,7 @@ final class ModFeatureIngester { } if (PatchDecoratorConverter.handles(placed)) { return new FeaturePlan(key, declaring, steps, null, - PatchDecoratorConverter.convert(placed, index, canopy.occlusion(key, declaring)), ""); + PatchDecoratorConverter.convert(placed, index, occlusion.occlusion(key, declaring)), ""); } } catch (IngestConversionException e) { return FeaturePlan.passthrough(key, declaring, steps, "generated natively: " + e.getMessage()); diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/PatchDecoratorConverter.java b/core/src/main/java/art/arcane/iris/pack/ingest/PatchDecoratorConverter.java index c995b732c..712dad309 100644 --- a/core/src/main/java/art/arcane/iris/pack/ingest/PatchDecoratorConverter.java +++ b/core/src/main/java/art/arcane/iris/pack/ingest/PatchDecoratorConverter.java @@ -45,30 +45,23 @@ final class PatchDecoratorConverter { return type.equals(SIMPLE_BLOCK) || LEGACY_PATCH_TYPES.contains(type); } - static JsonObject convert(CatalogPlacedFeature feature, WorldgenCatalogIndex index, double canopy) throws IngestConversionException { + static JsonObject convert(CatalogPlacedFeature feature, WorldgenCatalogIndex index, + VegetationOcclusion.Occlusion occlusion) throws IngestConversionException { String type = VanillaJson.namespaced(feature.featureType()); JsonObject config = VanillaJson.featureConfig(feature.config()); VanillaPlacementChain outer = VanillaPlacementChain.surfaceChain(VanillaJson.placement(feature.placement())); Set whitelist = new LinkedHashSet<>(outer.whitelist()); - double expected; JsonElement provider; if (LEGACY_PATCH_TYPES.contains(type)) { - int tries = VanillaJson.integer(config, "tries", LEGACY_DEFAULT_TRIES, "patch"); - int verticalSpread = Math.abs(VanillaJson.integer(config, "y_spread", LEGACY_DEFAULT_Y_SPREAD, "patch")); - double surfaceHitChance = outer.surfaceHitChance() / (verticalSpread + 1D); NestedBlock nested = nestedBlock(config.get("feature"), index); whitelist.addAll(nested.whitelist()); provider = nested.provider(); - expected = outer.expectedPlacements() * tries * surfaceHitChance; } else { provider = config.get("to_place"); - expected = outer.expectedPlacements() * outer.surfaceHitChance(); - } - if (expected <= 0D) { - throw new IngestConversionException("patch placement never places"); } + double expected = expectedPlacements(feature, index) * (1D - clamp(occlusion.occupancy())); if (outer.leafBlocked()) { - expected *= 1D - Math.max(0D, Math.min(1D, canopy)); + expected *= 1D - clamp(occlusion.canopy()); } List palette = VanillaBlockStates.palette(provider); JsonObject decorator = new JsonObject(); @@ -90,6 +83,27 @@ final class PatchDecoratorConverter { return decorator; } + static double expectedPlacements(CatalogPlacedFeature feature, WorldgenCatalogIndex index) throws IngestConversionException { + JsonObject config = VanillaJson.featureConfig(feature.config()); + VanillaPlacementChain outer = VanillaPlacementChain.surfaceChain(VanillaJson.placement(feature.placement())); + double expected; + if (LEGACY_PATCH_TYPES.contains(VanillaJson.namespaced(feature.featureType()))) { + int tries = VanillaJson.integer(config, "tries", LEGACY_DEFAULT_TRIES, "patch"); + int verticalSpread = Math.abs(VanillaJson.integer(config, "y_spread", LEGACY_DEFAULT_Y_SPREAD, "patch")); + expected = outer.expectedPlacements() * tries * outer.surfaceHitChance() / (verticalSpread + 1D); + } else { + expected = outer.expectedPlacements() * outer.surfaceHitChance(); + } + if (expected <= 0D) { + throw new IngestConversionException("patch placement never places"); + } + return expected; + } + + private static double clamp(double share) { + return Math.max(0D, Math.min(1D, share)); + } + static double columnCoverage(double expectedPlacements) { return 1D - Math.exp(-expectedPlacements / COLUMNS_PER_CHUNK); } diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/CanopyEstimate.java b/core/src/main/java/art/arcane/iris/pack/ingest/VegetationOcclusion.java similarity index 61% rename from core/src/main/java/art/arcane/iris/pack/ingest/CanopyEstimate.java rename to core/src/main/java/art/arcane/iris/pack/ingest/VegetationOcclusion.java index 90ce2b477..fb6d246a5 100644 --- a/core/src/main/java/art/arcane/iris/pack/ingest/CanopyEstimate.java +++ b/core/src/main/java/art/arcane/iris/pack/ingest/VegetationOcclusion.java @@ -27,7 +27,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; -final class CanopyEstimate { +final class VegetationOcclusion { static final double CROWN_COLUMNS = 20D; private static final double UNKNOWN_TREE_COUNT = 1D; private static final double COLUMNS_PER_CHUNK = 256D; @@ -36,39 +36,55 @@ final class CanopyEstimate { private final WorldgenCatalogIndex index; private final TreeFeatures trees; - CanopyEstimate(WorldgenCatalogIndex index) { + VegetationOcclusion(WorldgenCatalogIndex index) { this.index = index; this.trees = new TreeFeatures(index); } - double occlusion(String featureKey, Set declaringBiomes) { + Occlusion occlusion(String featureKey, Set declaringBiomes) { if (declaringBiomes.isEmpty()) { - return 0D; + return Occlusion.NONE; } - double total = 0D; + double canopy = 0D; + double occupancy = 0D; for (String biomeKey : declaringBiomes) { Optional biome = index.biome(biomeKey); - total += biome.map((CatalogBiome value) -> canopyBefore(value, featureKey)).orElse(0D); + if (biome.isEmpty() || biome.get().featureSteps().size() <= VEGETAL_STEP) { + continue; + } + Occlusion before = before(biome.get().featureSteps().get(VEGETAL_STEP), featureKey); + canopy += before.canopy(); + occupancy += before.occupancy(); } - return total / declaringBiomes.size(); + return new Occlusion(canopy / declaringBiomes.size(), occupancy / declaringBiomes.size()); } - private double canopyBefore(CatalogBiome biome, String featureKey) { - if (biome.featureSteps().size() <= VEGETAL_STEP) { - return 0D; - } - List vegetal = biome.featureSteps().get(VEGETAL_STEP); + private Occlusion before(List vegetal, String featureKey) { double treesPerChunk = 0D; + double open = 1D; for (String candidate : vegetal) { if (candidate.equals(featureKey)) { break; } Optional feature = index.placedFeature(candidate); - if (feature.isPresent() && trees.isTree(feature.get())) { + if (feature.isEmpty()) { + continue; + } + if (trees.isTree(feature.get())) { treesPerChunk += treesPerChunk(feature.get()); + } else if (PatchDecoratorConverter.handles(feature.get())) { + open *= 1D - surfaceCoverage(feature.get()); } } - return 1D - Math.exp(-treesPerChunk * CROWN_COLUMNS / COLUMNS_PER_CHUNK); + return new Occlusion(1D - Math.exp(-treesPerChunk * CROWN_COLUMNS / COLUMNS_PER_CHUNK), 1D - open); + } + + private double surfaceCoverage(CatalogPlacedFeature patch) { + try { + return PatchDecoratorConverter.columnCoverage(PatchDecoratorConverter.expectedPlacements(patch, index)); + } catch (IngestConversionException unknownDensity) { + return 0D; + } } private static double treesPerChunk(CatalogPlacedFeature tree) { @@ -78,4 +94,8 @@ final class CanopyEstimate { return UNKNOWN_TREE_COUNT; } } + + record Occlusion(double canopy, double occupancy) { + static final Occlusion NONE = new Occlusion(0D, 0D); + } } diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/FeatureConverterFormatTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/FeatureConverterFormatTest.java index 63408752f..00bfd73a3 100644 --- a/core/src/test/java/art/arcane/iris/pack/ingest/FeatureConverterFormatTest.java +++ b/core/src/test/java/art/arcane/iris/pack/ingest/FeatureConverterFormatTest.java @@ -115,7 +115,7 @@ public class FeatureConverterFormatTest { + "{\"type\": \"minecraft:heightmap\", \"heightmap\": \"WORLD_SURFACE_WG\"}, {\"type\": \"minecraft:biome\"}," + "{\"type\": \"minecraft:count\", \"count\": 96}," + PATCH_TAIL + "]"); - JsonObject decorator = PatchDecoratorConverter.convert(lavender, index(), 0D); + JsonObject decorator = PatchDecoratorConverter.convert(lavender, index(), VegetationOcclusion.Occlusion.NONE); assertEquals(1.0D - Math.exp(-(15.0D * 96.0D * 0.25D) / 256.0D), decorator.get("chance").getAsDouble(), 0.000001D); assertTrue(decorator.get("chance").getAsDouble() < 1.0D); @@ -133,9 +133,11 @@ public class FeatureConverterFormatTest { + "{\"type\": \"minecraft:heightmap\", \"heightmap\": \"MOTION_BLOCKING_NO_LEAVES\"}, {\"type\": \"minecraft:biome\"}]"); assertEquals(1.0D - Math.exp(-(4.0D * 0.5D) / 256.0D), - PatchDecoratorConverter.convert(lavender, index(), 0.5D).get("chance").getAsDouble(), 0.000001D); + PatchDecoratorConverter.convert(lavender, index(), new VegetationOcclusion.Occlusion(0.5D, 0D)).get("chance").getAsDouble(), 0.000001D); assertEquals(1.0D - Math.exp(-4.0D / 256.0D), - PatchDecoratorConverter.convert(underLeaves, index(), 0.5D).get("chance").getAsDouble(), 0.000001D); + PatchDecoratorConverter.convert(underLeaves, index(), new VegetationOcclusion.Occlusion(0.5D, 0D)).get("chance").getAsDouble(), 0.000001D); + assertEquals(1.0D - Math.exp(-(4.0D * 0.25D) / 256.0D), + PatchDecoratorConverter.convert(underLeaves, index(), new VegetationOcclusion.Occlusion(0D, 0.75D)).get("chance").getAsDouble(), 0.000001D); } @Test @@ -152,7 +154,7 @@ public class FeatureConverterFormatTest { {"type": "minecraft:biome"}] """); - JsonObject decorator = PatchDecoratorConverter.convert(flowers, index(), 0D); + JsonObject decorator = PatchDecoratorConverter.convert(flowers, index(), VegetationOcclusion.Occlusion.NONE); assertEquals(1.0D - Math.exp(-(0.5D * 0.2D * 0.25D) / 256.0D), decorator.get("chance").getAsDouble(), 0.000001D); assertEquals("minecraft:grass_block", decorator.getAsJsonArray("whitelist").get(0).getAsJsonObject().get("block").getAsString()); @@ -167,8 +169,8 @@ public class FeatureConverterFormatTest { "{\"type\": \"minecraft:simple_block\", \"to_place\": \"minecraft:flower_plain\"}", "[{\"type\": \"minecraft:heightmap\", \"heightmap\": \"MOTION_BLOCKING\"}]"); - assertThrows(IngestConversionException.class, () -> PatchDecoratorConverter.convert(seagrass, index(), 0D)); - IngestConversionException reference = assertThrows(IngestConversionException.class, () -> PatchDecoratorConverter.convert(plains, index(), 0D)); + assertThrows(IngestConversionException.class, () -> PatchDecoratorConverter.convert(seagrass, index(), VegetationOcclusion.Occlusion.NONE)); + IngestConversionException reference = assertThrows(IngestConversionException.class, () -> PatchDecoratorConverter.convert(plains, index(), VegetationOcclusion.Occlusion.NONE)); assertTrue(reference.getMessage(), reference.getMessage().contains("minecraft:flower_plain")); } diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java index 24bfcf22b..c3d4ae0db 100644 --- a/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java +++ b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java @@ -123,7 +123,7 @@ public class ModIngesterFeatureTest { assertNotNull(mapleFlowers); assertNotNull(forestFlowers); assertEquals(2, forest.getAsJsonArray("decorators").size()); - double mapleCanopy = 1.0D - Math.exp(-CanopyEstimate.CROWN_COLUMNS / 256.0D); + double mapleCanopy = 1.0D - Math.exp(-VegetationOcclusion.CROWN_COLUMNS / 256.0D); double openShare = 1.0D - mapleCanopy / 2.0D; assertEquals(1.0D - Math.exp(-(1.0D / 4.0D * 32.0D * 0.25D * openShare) / 256.0D), forestFlowers.get("chance").getAsDouble(), 0.000001D); JsonArray palette = forestFlowers.getAsJsonArray("palette"); diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/CanopyEstimateTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/VegetationOcclusionTest.java similarity index 61% rename from core/src/test/java/art/arcane/iris/pack/ingest/CanopyEstimateTest.java rename to core/src/test/java/art/arcane/iris/pack/ingest/VegetationOcclusionTest.java index a4ad5bcf3..4506acc99 100644 --- a/core/src/test/java/art/arcane/iris/pack/ingest/CanopyEstimateTest.java +++ b/core/src/test/java/art/arcane/iris/pack/ingest/VegetationOcclusionTest.java @@ -33,26 +33,42 @@ import java.util.Set; import static org.junit.Assert.assertEquals; -public class CanopyEstimateTest { - private static final double CANOPY = 1D - Math.exp(-10D * CanopyEstimate.CROWN_COLUMNS / 256D); +public class VegetationOcclusionTest { + private static final double CANOPY = 1D - Math.exp(-10D * VegetationOcclusion.CROWN_COLUMNS / 256D); + private static final double GRASS_COVERAGE = 1D - Math.exp(-(24D * 32D * 0.25D) / 256D); @Test public void flowersPlacedAfterTreesSeeTheTreeCanopy() { - CanopyEstimate estimate = new CanopyEstimate(WorldgenCatalogIndex.of(catalog())); + VegetationOcclusion estimate = new VegetationOcclusion(WorldgenCatalogIndex.of(catalog())); - assertEquals(CANOPY, estimate.occlusion("mod:flowers_after", Set.of("mod:woods")), 0.000001D); - assertEquals(0D, estimate.occlusion("mod:flowers_before", Set.of("mod:woods")), 0.000001D); - assertEquals(CANOPY / 2D, estimate.occlusion("mod:flowers_after", Set.of("mod:woods", "mod:meadow")), 0.000001D); + assertEquals(CANOPY, estimate.occlusion("mod:flowers_after", Set.of("mod:woods")).canopy(), 0.000001D); + assertEquals(0D, estimate.occlusion("mod:flowers_before", Set.of("mod:woods")).canopy(), 0.000001D); + assertEquals(CANOPY / 2D, estimate.occlusion("mod:flowers_after", Set.of("mod:woods", "mod:meadow")).canopy(), 0.000001D); + } + + @Test + public void surfacePatchesPlacedEarlierOccupyTheirShareOfColumns() { + VegetationOcclusion estimate = new VegetationOcclusion(WorldgenCatalogIndex.of(catalog())); + + assertEquals(GRASS_COVERAGE, estimate.occlusion("mod:flowers_after", Set.of("mod:woods")).occupancy(), 0.000001D); + assertEquals(0D, estimate.occlusion("mod:flowers_before", Set.of("mod:woods")).occupancy(), 0.000001D); + assertEquals(GRASS_COVERAGE / 2D, estimate.occlusion("mod:flowers_after", Set.of("mod:woods", "mod:meadow")).occupancy(), 0.000001D); } private static WorldgenCatalog catalog() { List biomes = List.of( - biome("mod:woods", List.of("mod:flowers_before", "mod:oak_trees", "mod:flowers_after")), + biome("mod:woods", List.of("mod:flowers_before", "mod:oak_trees", "mod:grass_patch", "mod:flowers_after")), biome("mod:meadow", List.of("mod:flowers_after"))); List features = new ArrayList<>(); features.add(new CatalogPlacedFeature("mod:oak_trees", "mod:oak_trees", "minecraft:tree", JsonParser.parseString("{}"), JsonParser.parseString("[{\"type\": \"minecraft:count\", \"count\": 10}, {\"type\": \"minecraft:in_square\"}," + "{\"type\": \"minecraft:surface_water_depth_filter\", \"max_water_depth\": 0}, {\"type\": \"minecraft:biome\"}]"))); + features.add(new CatalogPlacedFeature("mod:grass_patch", "mod:grass_patch", "minecraft:simple_block", + JsonParser.parseString("{\"to_place\": {\"id\": \"minecraft:short_grass\"}}"), + JsonParser.parseString("[{\"type\": \"minecraft:count\", \"count\": 24}, {\"type\": \"minecraft:in_square\"}," + + "{\"type\": \"minecraft:heightmap\", \"heightmap\": \"WORLD_SURFACE_WG\"}, {\"type\": \"minecraft:biome\"}," + + "{\"type\": \"minecraft:count\", \"count\": 32}," + + "{\"type\": \"minecraft:offset\", \"y\": {\"type\": \"minecraft:trapezoid\", \"min\": -3, \"max\": 3, \"plateau\": 0}}]"))); return new WorldgenCatalog(WorldgenCatalog.FORMAT, "26.3", "fabric", 1L, "canopy", List.of(new CatalogNamespace("mod", "Mod", "1")), biomes, features, List.of(), List.of(), List.of(), Map.of()); }