From febe930db0040e23e0e4a1c464f0942df998c560 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 7 Jun 2026 00:04:20 -0400 Subject: [PATCH] dwa --- .gitignore | 2 + core/plugins/Iris/cache/instance | 2 +- .../components/MantleObjectComponent.java | 36 +++ .../engine/object/DecayControlPlacer.java | 102 ++++++ .../arcane/iris/engine/object/IrisBiome.java | 2 + .../engine/object/IrisProceduralObjects.java | 44 +++ .../engine/object/IrisProceduralTree.java | 272 ++++++++++++++++ .../engine/object/IrisTreeAzimuthMode.java | 45 +++ .../object/IrisTreeBranchProbability.java | 47 +++ .../iris/engine/object/IrisTreeBranches.java | 120 ++++++++ .../iris/engine/object/IrisTreeCanopy.java | 63 ++++ .../iris/engine/object/IrisTreeDecorator.java | 59 ++++ .../object/IrisTreeDecoratorTarget.java | 45 +++ .../iris/engine/object/IrisTreeFunction.java | 47 +++ .../iris/engine/object/IrisTreeLayer.java | 42 +++ .../iris/engine/object/IrisTreeLeafMode.java | 43 +++ .../iris/engine/object/IrisTreeProfile.java | 53 ++++ .../engine/object/IrisTreeSecondaryLeaf.java | 44 +++ .../engine/object/IrisTreeSubBranches.java | 60 ++++ .../object/tree/ProceduralTreeGenerator.java | 118 +++++++ .../engine/object/tree/TreeBlockCanvas.java | 102 ++++++ .../engine/object/tree/TreeBlockResolver.java | 176 +++++++++++ .../engine/object/tree/TreeCanopyBuilder.java | 291 ++++++++++++++++++ .../object/tree/TreeDecoratorApplier.java | 280 +++++++++++++++++ .../engine/object/tree/TreeFunctions.java | 197 ++++++++++++ .../engine/object/tree/TreePlausibility.java | 106 +++++++ .../iris/engine/object/tree/TreeProfiles.java | 79 +++++ .../engine/object/tree/TreeRootBuilder.java | 106 +++++++ .../engine/object/tree/TreeTrunkBuilder.java | 164 ++++++++++ .../tree/ProceduralTreeGeneratorTest.java | 191 ++++++++++++ 30 files changed, 2937 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/art/arcane/iris/engine/object/DecayControlPlacer.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisProceduralObjects.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisTreeAzimuthMode.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisTreeBranchProbability.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisTreeBranches.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisTreeCanopy.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisTreeDecorator.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisTreeDecoratorTarget.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisTreeFunction.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisTreeLayer.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisTreeLeafMode.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisTreeProfile.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisTreeSecondaryLeaf.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisTreeSubBranches.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/tree/ProceduralTreeGenerator.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockCanvas.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/tree/TreeCanopyBuilder.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplier.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/tree/TreeFunctions.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/tree/TreePlausibility.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/tree/TreeProfiles.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/tree/TreeRootBuilder.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java diff --git a/.gitignore b/.gitignore index 215c00356..a6992a671 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ collection/ /core/src/main/java/art/arcane/iris/util/uniques/ DataPackExamples/ + +TreeGenStuff/ diff --git a/core/plugins/Iris/cache/instance b/core/plugins/Iris/cache/instance index dec88e772..7e70949d1 100644 --- a/core/plugins/Iris/cache/instance +++ b/core/plugins/Iris/cache/instance @@ -1 +1 @@ --676456073 \ No newline at end of file +-1963992339 \ No newline at end of file diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java index 5abe33e7a..3b6c33afd 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java @@ -128,6 +128,7 @@ public class MantleObjectComponent extends IrisMantleComponent { + " regionCavePlacers=" + region.getCarvingObjects().size()); } ObjectPlacementSummary summary = placeObjects(writer, rng, x, z, surfaceBiome, caveBiome, region, complex, traceRegen, surfaceHeightLookup); + placeProceduralTrees(writer, rng, x, z, surfaceBiome); UpperDimensionContext upperCtx = getEngineMantle().getEngine().getUpperContext(); IrisDimension dimension = getDimension(); if (upperCtx != null && dimension.isUpperDimensionObjects()) { @@ -358,6 +359,41 @@ public class MantleObjectComponent extends IrisMantleComponent { ); } + @ChunkCoordinates + private void placeProceduralTrees(MantleWriter writer, RNG rng, int x, int z, IrisBiome surfaceBiome) { + IrisProceduralObjects proceduralObjects = surfaceBiome.getProceduralObjects(); + if (proceduralObjects == null || proceduralObjects.isEmpty()) { + return; + } + + int blockX = x << 4; + int blockZ = z << 4; + for (IrisProceduralTree tree : proceduralObjects.getTrees()) { + if (!rng.chance(tree.getChance() + rng.d(-0.005, 0.005))) { + continue; + } + + IrisObjectPlacement placement = tree.asPlacement(); + IObjectPlacer placer = tree.isPlausible() ? new DecayControlPlacer(writer) : writer; + int density = Math.max(1, tree.getDensity()); + for (int i = 0; i < density; i++) { + IrisObject variant = tree.getVariantObject(getData(), rng); + if (variant == null) { + continue; + } + int xx = rng.i(blockX, blockX + 15); + int zz = rng.i(blockZ, blockZ + 15); + try { + variant.place(xx, -1, zz, placer, placement, rng, getData()); + } catch (Throwable e) { + Iris.reportError(e); + Iris.error("Failed to place procedural tree '" + tree.getName() + "' in biome " + surfaceBiome.getName()); + e.printStackTrace(); + } + } + } + } + @BlockCoordinates private ObjectPlacementResult placeObject( MantleWriter writer, diff --git a/core/src/main/java/art/arcane/iris/engine/object/DecayControlPlacer.java b/core/src/main/java/art/arcane/iris/engine/object/DecayControlPlacer.java new file mode 100644 index 000000000..50f630adb --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/DecayControlPlacer.java @@ -0,0 +1,102 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import org.bukkit.block.data.BlockData; +import org.jetbrains.annotations.Nullable; + +public class DecayControlPlacer implements IObjectPlacer { + private final IObjectPlacer delegate; + + public DecayControlPlacer(IObjectPlacer delegate) { + this.delegate = delegate; + } + + @Override + public int getHighest(int x, int z, IrisData data) { + return delegate.getHighest(x, z, data); + } + + @Override + public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { + return delegate.getHighest(x, z, data, ignoreFluid); + } + + @Override + public void set(int x, int y, int z, BlockData d) { + delegate.set(x, y, z, d); + } + + @Override + public BlockData get(int x, int y, int z) { + return delegate.get(x, y, z); + } + + @Override + public boolean isPreventingDecay() { + return false; + } + + @Override + public boolean isCarved(int x, int y, int z) { + return delegate.isCarved(x, y, z); + } + + @Override + public boolean isSolid(int x, int y, int z) { + return delegate.isSolid(x, y, z); + } + + @Override + public boolean isUnderwater(int x, int z) { + return delegate.isUnderwater(x, z); + } + + @Override + public int getFluidHeight() { + return delegate.getFluidHeight(); + } + + @Override + public boolean isDebugSmartBore() { + return delegate.isDebugSmartBore(); + } + + @Override + public void setTile(int xx, int yy, int zz, TileData tile) { + delegate.setTile(xx, yy, zz, tile); + } + + @Override + public void setData(int xx, int yy, int zz, T data) { + delegate.setData(xx, yy, zz, data); + } + + @Override + public @Nullable T getData(int xx, int yy, int zz, Class t) { + return delegate.getData(xx, yy, zz, t); + } + + @Override + public Engine getEngine() { + return delegate.getEngine(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index d329e0da5..c9d0fcd21 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -165,6 +165,8 @@ public class IrisBiome extends IrisRegistrant implements IRare { @ArrayType(min = 1, type = IrisObjectPlacement.class) @Desc("Objects define what schematics (iob files) iris will place in this biome") private KList objects = new KList<>(); + @Desc("Procedural objects (such as trees) iris generates from scratch and places in this biome, instead of loading them from iob files") + private IrisProceduralObjects proceduralObjects = new IrisProceduralObjects(); @ArrayType(min = 1, type = IrisStructurePlacement.class) @Desc("Structures define jigsaw or vanilla/datapack structures iris will place in this biome") private KList structures = new KList<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralObjects.java b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralObjects.java new file mode 100644 index 000000000..a11a7a41c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralObjects.java @@ -0,0 +1,44 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.Snippet; +import art.arcane.volmlib.util.collection.KList; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("procedural-objects") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("Procedurally generated objects placed in a biome. Unlike the objects block (which loads iob files), these are generated from scratch at world-gen time.") +@Data +public class IrisProceduralObjects { + @ArrayType(min = 1, type = IrisProceduralTree.class) + @Desc("Procedurally generated trees placed in this biome.") + private KList trees = new KList<>(); + + public boolean isEmpty() { + return trees == null || trees.isEmpty(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java new file mode 100644 index 000000000..5fc9216ae --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java @@ -0,0 +1,272 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Required; +import art.arcane.iris.engine.object.annotations.Snippet; +import art.arcane.iris.engine.object.tree.ProceduralTreeGenerator; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("procedural-tree") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("A single procedurally generated tree. Iris bakes a pool of deterministic variants from these settings and scatters them at world-gen time, exactly like an object placement but generated from scratch instead of loaded from an iob file.") +@Data +public class IrisProceduralTree { + private final transient AtomicCache> variantCache = new AtomicCache<>(); + + @Desc("A human readable name used in logs and as the variant load key.") + private String name = "procedural-tree"; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The chance per chunk for this tree to attempt placement. Use density for multiple per chunk.") + private double chance = 0.4; + + @MinNumber(1) + @Desc("If the chance check passes, attempt this many placements in the chunk.") + private int density = 1; + + @MinNumber(1) + @MaxNumber(64) + @Desc("How many distinct variants to pre-bake for this tree. Higher means more variety at a small memory cost.") + private int variants = 8; + + @Desc("The base seed for deterministic generation. The same seed and settings always bake the same variants.") + private long seed = 1337; + + @Desc("If true (default) leaves are placed with vanilla distance-from-wood values and persistent=false so leaf decay behaves naturally and the tree reads as plausible. If false, leaves are forced persistent and never decay (a raw object dump).") + private boolean plausible = true; + + @Desc("The placement mode used to anchor the tree to the terrain.") + private ObjectPlaceMode mode = ObjectPlaceMode.CENTER_HEIGHT; + + @Desc("Rotate this tree's placement.") + private IrisObjectRotation rotation = new IrisObjectRotation(); + + @Desc("Limit the max or min height of placement.") + private IrisObjectLimit clamp = new IrisObjectLimit(); + + @Desc("Whether this tree may place on the terrain surface, under carvings, or both.") + private CarvingMode carvingSupport = CarvingMode.SURFACE_ONLY; + + @Desc("If true, the tree anchors on the terrain height ignoring the water surface.") + private boolean underwater = false; + + @Required + @Desc("The trunk (log) block, e.g. minecraft:oak_log. Ignored when trunkPalette is set.") + private String trunk = "minecraft:oak_log"; + + @Desc("A noise-driven palette for the trunk. When set this overrides the single trunk block, letting the trunk mix blocks by noise.") + private IrisMaterialPalette trunkPalette = null; + + @Required + @Desc("The leaf block, e.g. minecraft:oak_leaves. Ignored when leavesPalette is set.") + private String leaves = "minecraft:oak_leaves"; + + @Desc("A noise-driven palette for the leaves. When set this overrides the single leaf block, letting the canopy mix leaf (or other) blocks by noise. Only blocks that are actually leaves get vanilla decay distances.") + private IrisMaterialPalette leavesPalette = null; + + @Desc("The named canopy profile that drives default crown radii and shape.") + private IrisTreeProfile profile = IrisTreeProfile.OAK; + + @MinNumber(1) + @Desc("Minimum trunk height in blocks.") + private int heightMin = 8; + + @MinNumber(1) + @Desc("Maximum trunk height in blocks.") + private int heightMax = 12; + + @MinNumber(1) + @Desc("Base trunk width in blocks. 1 is a single column, 2 is a 2x2 trunk, etc.") + private int trunkWidth = 1; + + @Desc("The function used to shape trunk width over height.") + private IrisTreeFunction trunkShape = IrisTreeFunction.CONSTANT; + + @Desc("Width multiplier at the base for the LINEAR trunk shape.") + private double shapeStart = 1; + + @Desc("Width multiplier at the top for the LINEAR trunk shape.") + private double shapeEnd = 1; + + @Desc("Steepness for the SIGMOID trunk shape.") + private double shapeSteepness = 5; + + @Desc("Base for the LOG trunk shape.") + private double shapeBase = 2.718281828; + + @Desc("Period for the SINE trunk shape.") + private double shapePeriod = 1; + + @Desc("Amplitude for the SINE trunk shape.") + private double shapeAmplitude = 0.2; + + @Desc("Pinch position (0-1) for the PARABOLIC trunk shape.") + private double shapePeakOffset = 0.5; + + @Desc("Minimum width fraction at the waist for the PARABOLIC trunk shape.") + private double shapeFloor = 0.5; + + @Desc("Compass direction in degrees the trunk leans toward.") + private double leanAzimuth = 0; + + @Desc("How far the trunk leans, in degrees from vertical. 0 is perfectly upright.") + private double leanAngle = 0; + + @Desc("How the lean accumulates over height.") + private IrisTreeFunction trunkCurve = IrisTreeFunction.LINEAR; + + @Desc("Steepness for the SIGMOID trunk curve.") + private double curveSteepness = 8; + + @Desc("How the lean azimuth itself changes over height (for spiraling or wandering trunks).") + private IrisTreeAzimuthMode leanAzimuthMode = IrisTreeAzimuthMode.CONSTANT; + + @Desc("Azimuth at the base for the LINEAR azimuth mode.") + private double azimuthStart = 0; + + @Desc("Azimuth at the top for the LINEAR azimuth mode.") + private double azimuthEnd = 0; + + @Desc("Number of full turns for the SPIRAL azimuth mode.") + private double azimuthTurns = 1; + + @Desc("Amplitude for the SINE azimuth mode.") + private double azimuthAmplitude = 90; + + @Desc("Period for the SINE azimuth mode.") + private double azimuthPeriod = 1; + + @Desc("Offset for the SINE azimuth mode.") + private double azimuthOffset = 0; + + @Desc("Scale for the NOISE azimuth mode.") + private double azimuthScale = 1; + + @MinNumber(1) + @Desc("Number of branches per whorl ring for the WHORL branch azimuth mode.") + private int azimuthWhorlCount = 5; + + @Desc("Optional accent leaf block scattered through the canopy (blossoms, shroomlight).") + private String secondaryLeaves = null; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The fraction of leaves replaced by the secondary leaf block(s).") + private double secondaryLeafFraction = 0.35; + + @ArrayType(min = 1, type = IrisTreeSecondaryLeaf.class) + @Desc("Optional weighted set of accent leaf blocks. When set this overrides the single secondaryLeaves block.") + private KList weightedSecondaryLeaves = new KList<>(); + + @Desc("A noise-driven palette for the secondary (accent) leaves. When set this overrides both secondaryLeaves and weightedSecondaryLeaves.") + private IrisMaterialPalette secondaryLeavesPalette = null; + + @Desc("Optional secondary trunk block used over a height band (for color-banded trunks). Ignored when secondaryTrunkPalette is set.") + private String secondaryTrunk = null; + + @Desc("A noise-driven palette for the secondary trunk band. When set this overrides the single secondaryTrunk block.") + private IrisMaterialPalette secondaryTrunkPalette = null; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The normalized height where the secondary trunk band starts.") + private double secondaryTrunkStart = 0.5; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The normalized height where the secondary trunk band ends.") + private double secondaryTrunkEnd = 1; + + @Desc("If true, a tapering taproot and flared buttress legs extend below the base so the tree connects to the ground on uneven terrain.") + private boolean roots = true; + + @Desc("The leaf crown configuration.") + private IrisTreeCanopy canopy = new IrisTreeCanopy(); + + @ArrayType(min = 1, type = IrisTreeDecorator.class) + @Desc("Accent decorators applied after the tree is built (fruit, vines, snow, glow).") + private KList decorators = new KList<>(); + + public KList getVariantObjects(IrisData data) { + return variantCache.aquire(() -> { + KList baked = new KList<>(); + int count = Math.max(1, variants); + int lo = Math.min(heightMin, heightMax); + int hi = Math.max(heightMin, heightMax); + RNG heightRng = new RNG(seed); + + for (int i = 0; i < count; i++) { + int height; + if (count == 1 || lo == hi) { + height = heightRng.i(lo, hi + 1); + } else { + double step = (hi - lo) / (double) (count - 1); + double base = lo + step * i; + double jitter = heightRng.d(-step * 0.3, step * 0.3); + height = (int) Math.round(Math.max(lo, Math.min(hi, base + jitter))); + } + + IrisObject object = ProceduralTreeGenerator.generate(this, Math.max(2, height), new RNG(seed + (i * 7919L)), data); + if (object == null || object.getBlocks().isEmpty()) { + continue; + } + object.setLoadKey("procedural/" + name + "#" + i); + object.setLoader(data); + baked.add(object); + } + + return baked; + }); + } + + public IrisObject getVariantObject(IrisData data, RNG rng) { + KList baked = getVariantObjects(data); + if (baked.isEmpty()) { + return null; + } + return baked.get(rng.i(baked.size())); + } + + public IrisObjectPlacement asPlacement() { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setMode(mode); + placement.setRotation(rotation); + placement.setClamp(clamp); + placement.setCarvingSupport(carvingSupport); + placement.setUnderwater(underwater); + placement.setChance(chance); + placement.setDensity(density); + return placement; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeAzimuthMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeAzimuthMode.java new file mode 100644 index 000000000..75215bdf7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeAzimuthMode.java @@ -0,0 +1,45 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Controls the compass direction (azimuth) of trunk lean as it rises, or the direction each branch is thrown.") +public enum IrisTreeAzimuthMode { + @Desc("A single fixed azimuth.") + CONSTANT, + @Desc("Azimuth ramps straight from start to end with height.") + LINEAR, + @Desc("Azimuth winds around the trunk a number of turns (corkscrew).") + SPIRAL, + @Desc("Azimuth oscillates with a sine wave.") + SINE, + @Desc("Azimuth wanders using deterministic noise.") + NOISE, + @Desc("Each branch picks a uniformly random azimuth (falls back to constant for trunk lean).") + RANDOM, + @Desc("Phyllotaxis: each successive branch is offset by the 137.5 degree golden angle for the most natural spiral packing (branches).") + GOLDEN_ANGLE, + @Desc("Each successive branch is thrown to the opposite side (180 degrees apart).") + ALTERNATING, + @Desc("Branches are grouped into evenly spaced whorls of azimuthWhorlCount around each ring.") + WHORL, + @Desc("Azimuth zigzags by +/- amplitude on alternating branches.") + ZIGZAG +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeBranchProbability.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeBranchProbability.java new file mode 100644 index 000000000..926f12073 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeBranchProbability.java @@ -0,0 +1,47 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Controls how the chance of spawning a branch varies with trunk height (0 at the base, 1 at the top).") +public enum IrisTreeBranchProbability { + @Desc("Same branch chance at every height.") + CONSTANT, + @Desc("Ramps straight from base chance to crown chance.") + LINEAR, + @Desc("S-curve that switches on around a midpoint height.") + SIGMOID, + @Desc("Chance rises with height raised to an exponent, clustering branches near the crown.") + TOP_HEAVY, + @Desc("Bell curve peaking in a height band.") + GAUSSIAN, + @Desc("Irregular organic branching from deterministic noise.") + NOISE, + @Desc("Chance falls with height (1-t)^exponent, clustering branches near the base.") + BOTTOM_HEAVY, + @Desc("Regular whorl rings of branches at evenly spaced heights (periods controls count).") + PERIODIC, + @Desc("Branches only within a height window around mean +/- std.") + BAND, + @Desc("Inverse of GAUSSIAN: branches avoid the mean band and favor base and crown.") + INVERSE_GAUSSIAN, + @Desc("Exponential decay from the base upward (constant * e^-exponent*t).") + EXPONENTIAL_DECAY +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeBranches.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeBranches.java new file mode 100644 index 000000000..74dfb6818 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeBranches.java @@ -0,0 +1,120 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Snippet; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("tree-branches") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("A branch system that throws limbs out of the trunk and places a leaf ball at each tip. When present, branches drive the canopy and only the topmost crown disc of the profile is still placed.") +@Data +public class IrisTreeBranches { + @Desc("How branch spawn chance varies over trunk height.") + private IrisTreeBranchProbability probabilityFunction = IrisTreeBranchProbability.TOP_HEAVY; + + @MinNumber(0) + @Desc("Branch chance for the CONSTANT probability function.") + private double probabilityConstant = 0.5; + + @MinNumber(0) + @Desc("Branch chance at the base for the LINEAR probability function.") + private double probabilityBase = 0; + + @MinNumber(0) + @Desc("Branch chance at the crown for the LINEAR probability function.") + private double probabilityCrown = 1; + + @Desc("Steepness for the SIGMOID probability function.") + private double probabilitySteepness = 10; + + @Desc("Midpoint height (0-1) for the SIGMOID probability function.") + private double probabilityMidpoint = 0.7; + + @Desc("Exponent for the TOP_HEAVY probability function.") + private double probabilityExponent = 2; + + @Desc("Mean height (0-1) for the GAUSSIAN probability function.") + private double probabilityMean = 0.7; + + @Desc("Standard deviation for the GAUSSIAN probability function.") + private double probabilityStd = 0.15; + + @Desc("Noise scale for the NOISE probability function.") + private double probabilityScale = 1; + + @MinNumber(0) + @Desc("Number of whorl rings over the trunk height for the PERIODIC probability function.") + private double probabilityPeriods = 5; + + @Desc("How branch length varies over trunk height.") + private IrisTreeFunction lengthFunction = IrisTreeFunction.LINEAR; + + @MinNumber(0) + @Desc("Branch length at the base for the LINEAR length function.") + private double lengthBase = 1; + + @MinNumber(0) + @Desc("Branch length at the crown for the LINEAR length function.") + private double lengthCrown = 4; + + @MinNumber(0) + @Desc("Branch length for the CONSTANT length function.") + private double lengthConstant = 3; + + @MinNumber(0) + @Desc("Maximum branch length for the SIGMOID, LOG and PARABOLIC length functions.") + private double lengthMax = 4; + + @Desc("Steepness for the SIGMOID length function.") + private double lengthSteepness = 5; + + @Desc("How the compass direction of each branch is chosen.") + private IrisTreeAzimuthMode azimuthMode = IrisTreeAzimuthMode.RANDOM; + + @Desc("Fixed azimuth in degrees when azimuthMode is CONSTANT.") + private double azimuth = 0; + + @Desc("Elevation angle in degrees from horizontal. 0 is flat, positive points up, negative droops down.") + private double elevation = 0; + + @Desc("If true, branches are clamped to never droop below horizontal (useful for upright species).") + private boolean leafStartUp = false; + + @MinNumber(0) + @Desc("Leaf ball radius placed at each branch tip.") + private int clusterRadius = 2; + + @Desc("How the branch tip leaf cluster fills.") + private IrisTreeLeafMode clusterMode = IrisTreeLeafMode.TRIMMED; + + @MinNumber(0) + @Desc("Fill density for the branch tip leaf cluster when clusterMode is density or noise.") + private double clusterDensity = 0.85; + + @Desc("Optional one level of recursive sub-branches sprouting from each branch tip.") + private IrisTreeSubBranches subBranches = null; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeCanopy.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeCanopy.java new file mode 100644 index 000000000..0a1e981f4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeCanopy.java @@ -0,0 +1,63 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Snippet; +import art.arcane.volmlib.util.collection.KList; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("tree-canopy") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("The leaf crown of a procedural tree. Built from stacked dome-shaped discs (driven by the profile or by explicit layers) and optionally a branch system.") +@Data +public class IrisTreeCanopy { + @MinNumber(0) + @MaxNumber(180) + @Desc("Elevation angle in degrees from vertical for each leaf dome. 90 is a flat disc, below 90 domes downward into a sphere, above 90 flares outward into an umbrella.") + private double startAngle = 90; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Vertical scale of the canopy volume. Lower values flatten the crown.") + private double squish = 1; + + @Desc("How leaves fill each canopy disc.") + private IrisTreeLeafMode mode = IrisTreeLeafMode.TRIMMED; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Fill probability used by the density and noise leaf modes.") + private double leafDensity = 0.85; + + @ArrayType(min = 1, type = IrisTreeLayer.class) + @Desc("Optional explicit crown discs. When provided these replace the profile-driven layers and let you sculpt the silhouette by hand.") + private KList layers = new KList<>(); + + @Desc("Optional branch system. When present, branches build most of the canopy and only the topmost profile disc is still placed as a crown.") + private IrisTreeBranches branches = null; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeDecorator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeDecorator.java new file mode 100644 index 000000000..a2e36428a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeDecorator.java @@ -0,0 +1,59 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Required; +import art.arcane.iris.engine.object.annotations.Snippet; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("tree-decorator") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("An accent block applied to a generated tree such as fruit on branch tips, vines on the trunk, snow on the crown, or a glowing underside.") +@Data +public class IrisTreeDecorator { + @Desc("Where this decorator is placed on the tree.") + private IrisTreeDecoratorTarget target = IrisTreeDecoratorTarget.BRANCH_TIP; + + @Required + @Desc("The block id to place, e.g. minecraft:magma_block. Ignored when palette is set.") + private String block = ""; + + @Desc("A noise-driven palette for this decorator. When set this overrides the single block.") + private IrisMaterialPalette palette = null; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The chance per candidate position for this decorator to place.") + private double chance = 0.5; + + @MinNumber(1) + @Desc("Maximum strand length downward for the CANOPY_HANG target (random 1..length per column).") + private int length = 1; + + @Desc("If true the block's facing is oriented away from the trunk (for trunk-mounted fences/gates/banners).") + private boolean axisAware = false; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeDecoratorTarget.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeDecoratorTarget.java new file mode 100644 index 000000000..cdf72f006 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeDecoratorTarget.java @@ -0,0 +1,45 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Where a tree decorator block is placed.") +public enum IrisTreeDecoratorTarget { + @Desc("At the tip of each generated branch (vines, fruit, lanterns).") + BRANCH_TIP, + @Desc("On air-facing sides of trunk and branch blocks (vines, moss).") + TRUNK_SURFACE, + @Desc("On top of the highest block in each column (snow, sculk).") + CANOPY_TOP, + @Desc("One block below the lowest leaf in each column (glowing underside).") + CANOPY_BOTTOM, + @Desc("Around the y=0 ring at the base of the trunk (roots, mushrooms).") + TRUNK_BASE, + @Desc("On air-facing sides of leaf blocks (berries, lights nestled in the foliage).") + LEAF_SURFACE, + @Desc("Hanging strands of up to 'length' blocks below the lowest leaf in each column (vines, icicles).") + CANOPY_HANG, + @Desc("On the top face of trunk and branch blocks (moss, snow on limbs).") + BRANCH_SURFACE, + @Desc("One block above the very top of the trunk (nest, beehive, beacon).") + TRUNK_TOP, + @Desc("Scattered across the ground footprint around the base, wider than the trunk-base ring.") + GROUND_SCATTER +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeFunction.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeFunction.java new file mode 100644 index 000000000..cada3dff2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeFunction.java @@ -0,0 +1,47 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("A shaping function evaluated over normalized height (0 at the base, 1 at the top). Used for trunk width, trunk curve and branch length.") +public enum IrisTreeFunction { + @Desc("Same value at every height.") + CONSTANT, + @Desc("Ramps straight from start to end.") + LINEAR, + @Desc("S-curve transition controlled by steepness.") + SIGMOID, + @Desc("Logarithmic falloff controlled by base.") + LOG, + @Desc("Sine ripple controlled by period and amplitude.") + SINE, + @Desc("Parabolic pinch, narrowest at peakOffset.") + PARABOLIC, + @Desc("Geometric (exponential) interpolation from start to end.") + EXPONENTIAL, + @Desc("Square-root ease: fast change low, slow high.") + SQRT, + @Desc("Hard step that jumps from start to end at the threshold (peakOffset).") + STEP, + @Desc("Bell bulge peaking in the middle of the height range.") + BELL, + @Desc("Smoothstep ease-in-out (3t^2 - 2t^3).") + EASE_IN_OUT +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeLayer.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeLayer.java new file mode 100644 index 000000000..8d3ed1acb --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeLayer.java @@ -0,0 +1,42 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Snippet; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("tree-layer") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("An explicit canopy disc at a given vertical offset and radius. Define a list of these to bypass the profile and sculpt the crown by hand.") +@Data +public class IrisTreeLayer { + @Desc("The vertical offset of this disc, measured in blocks above the trunk base.") + private int yOffset = 0; + + @MinNumber(0.5) + @Desc("The radius of this leaf disc in blocks.") + private double radius = 2; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeLeafMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeLeafMode.java new file mode 100644 index 000000000..fc02afca2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeLeafMode.java @@ -0,0 +1,43 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("How leaves fill a disc or cluster.") +public enum IrisTreeLeafMode { + @Desc("Corner-trimmed disc/sphere for a rounded silhouette.") + TRIMMED, + @Desc("Completely filled disc/sphere.") + FILLED, + @Desc("Seeded probabilistic falloff toward the edge controlled by leafDensity.") + DENSITY, + @Desc("Seeded value-noise mask controlled by leafDensity.") + NOISE, + @Desc("Only the outer shell is placed, leaving the interior hollow.") + HOLLOW, + @Desc("Quadratic radial gradient: dense at the center, thinning sharply toward the edge.") + GRADIENT, + @Desc("Coarse low-frequency noise that forms clumps and gaps of foliage.") + CLUMPED, + @Desc("Trimmed with heavy random erosion of the outer ring for a ragged, weathered look.") + TATTERED, + @Desc("Very thin scattering of leaves across the volume.") + SPARSE +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeProfile.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeProfile.java new file mode 100644 index 000000000..14f22d839 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeProfile.java @@ -0,0 +1,53 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Named canopy presets that drive default layer radii and shape, scaled to the tree height.") +public enum IrisTreeProfile { + @Desc("Rounded broadleaf crown.") + OAK, + @Desc("Slim upright broadleaf crown.") + BIRCH, + @Desc("Narrow conifer cone.") + SPRUCE, + @Desc("Tall thin crown concentrated near the very top.") + JUNGLE, + @Desc("Flat shallow umbrella crown.") + ACACIA, + @Desc("Dense rounded broadleaf crown.") + DARK_OAK, + @Desc("Flat wide umbrella crown compressed into the top quarter (vanilla dark oak look).") + DARK_OAK_FLAT, + @Desc("Extra wide flat umbrella that overlaps into a continuous roofed canopy.") + DARK_OAK_FLAT_WIDE, + @Desc("Broad rounded blossom crown.") + CHERRY, + @Desc("Bare trunk with a small tuft of fronds only at the very top.") + PALM, + @Desc("Broad crown with wide drooping radii, ideal with a low startAngle to make a weeping skirt.") + WILLOW, + @Desc("Very narrow tall crown hugging the trunk (cypress / lombardy poplar).") + COLUMNAR, + @Desc("Squat, wide, low crown for shrubs and saplings.") + BUSH, + @Desc("Giant conifer cone for 2x2 mega spruce trunks.") + MEGA_SPRUCE +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeSecondaryLeaf.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeSecondaryLeaf.java new file mode 100644 index 000000000..01852673d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeSecondaryLeaf.java @@ -0,0 +1,44 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Required; +import art.arcane.iris.engine.object.annotations.Snippet; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("tree-secondary-leaf") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("A weighted accent leaf or foliage block scattered through the canopy (blossoms, shroomlight, berries).") +@Data +public class IrisTreeSecondaryLeaf { + @Required + @Desc("The block id to scatter, e.g. minecraft:shroomlight") + private String block = ""; + + @MinNumber(0) + @Desc("The relative weight of this block among the secondary leaves.") + private int weight = 1; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTreeSubBranches.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeSubBranches.java new file mode 100644 index 000000000..19b45123d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTreeSubBranches.java @@ -0,0 +1,60 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Snippet; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("tree-sub-branches") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("One recursive level of sub-branches sprouting from each primary branch tip.") +@Data +public class IrisTreeSubBranches { + @MinNumber(1) + @Desc("How many sub-branches sprout from each primary branch tip.") + private int count = 1; + + @Desc("Pitch deflection in degrees applied to each sub-branch relative to its parent. Positive bends up, negative droops down. Cumulative pitch past 90 makes sub-branches point downward.") + private double pitchDelta = 0; + + @Desc("Yaw (horizontal) spread in degrees fanned between sub-branches.") + private double yawDelta = 45; + + @MinNumber(0) + @Desc("Sub-branch length as a fraction of its parent branch length.") + private double lengthScale = 0.5; + + @MinNumber(0) + @Desc("Leaf ball radius placed at each sub-branch tip.") + private int clusterRadius = 1; + + @Desc("How the sub-branch tip leaf cluster fills.") + private IrisTreeLeafMode clusterMode = IrisTreeLeafMode.TRIMMED; + + @MinNumber(0) + @Desc("Fill density for the sub-branch leaf cluster when clusterMode is density or noise.") + private double clusterDensity = 0.85; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/ProceduralTreeGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/tree/ProceduralTreeGenerator.java new file mode 100644 index 000000000..35413bf7d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/ProceduralTreeGenerator.java @@ -0,0 +1,118 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.tree; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisProceduralTree; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.block.data.BlockData; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class ProceduralTreeGenerator { + private ProceduralTreeGenerator() { + } + + public static IrisObject generate(IrisProceduralTree tree, int height, RNG rng, IrisData data) { + TreeBlockCanvas canvas = new TreeBlockCanvas(); + long baseSeed = rng.getSeed(); + + double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height); + + boolean wantEndpoints = tree.getDecorators() != null && !tree.getDecorators().isEmpty(); + List branchEndpoints = wantEndpoints ? new ArrayList<>() : null; + + TreeCanopyBuilder.build(canvas, tree, height, offsets, baseSeed, branchEndpoints); + + if (wantEndpoints) { + TreeDecoratorApplier.apply(canvas, tree, baseSeed, branchEndpoints); + } + + if (tree.isRoots()) { + TreeRootBuilder.build(canvas, tree, height, baseSeed); + } + + Map resolved = new HashMap<>(); + Set trunkPositions = new HashSet<>(); + Set leafPositions = new HashSet<>(); + for (Map.Entry entry : canvas.getCells().entrySet()) { + TreeBlockCanvas.Cell cell = entry.getValue(); + BlockData bd = TreeBlockResolver.resolve(tree, data, cell, entry.getKey()); + if (bd == null) { + continue; + } + resolved.put(entry.getKey(), bd); + if (cell.role() == TreeBlockCanvas.Role.TRUNK || cell.role() == TreeBlockCanvas.Role.SECONDARY_TRUNK) { + trunkPositions.add(entry.getKey()); + } else if (cell.role() == TreeBlockCanvas.Role.LEAF || cell.role() == TreeBlockCanvas.Role.SECONDARY_LEAF) { + leafPositions.add(entry.getKey()); + } + } + + TreePlausibility.apply(resolved, trunkPositions, leafPositions, tree); + + return assemble(resolved); + } + + private static IrisObject assemble(Map resolved) { + if (resolved.isEmpty()) { + return null; + } + + 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 (TreeBlockCanvas.Vec v : resolved.keySet()) { + minX = Math.min(minX, v.x()); + minY = Math.min(minY, v.y()); + minZ = Math.min(minZ, v.z()); + maxX = Math.max(maxX, v.x()); + maxY = Math.max(maxY, v.y()); + maxZ = Math.max(maxZ, v.z()); + } + + int w = maxX - minX + 1; + int h = maxY - minY + 1; + int d = maxZ - minZ + 1; + int cx = w / 2; + int cy = h / 2; + int cz = d / 2; + + IrisObject object = new IrisObject(w, h, d); + for (Map.Entry entry : resolved.entrySet()) { + TreeBlockCanvas.Vec v = entry.getKey(); + int nx = v.x() - minX - cx; + int ny = v.y() - cy + 1; + int nz = v.z() - minZ - cz; + object.getBlocks().put(new Vector3i(nx, ny, nz), entry.getValue()); + } + + return object; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockCanvas.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockCanvas.java new file mode 100644 index 000000000..1a70a4c6a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockCanvas.java @@ -0,0 +1,102 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.tree; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public class TreeBlockCanvas { + public record Vec(int x, int y, int z) { + } + + public enum Role { + TRUNK, SECONDARY_TRUNK, LEAF, SECONDARY_LEAF, DECORATOR + } + + public enum Axis { + NONE, X, Y, Z + } + + public record Cell(Role role, Axis axis, boolean exposed, int decoratorIndex, String facing) { + } + + private final Map cells = new HashMap<>(); + private final Set trunk = new HashSet<>(); + private final Set leaf = new HashSet<>(); + + public boolean has(int x, int y, int z) { + return cells.containsKey(new Vec(x, y, z)); + } + + public Cell get(int x, int y, int z) { + return cells.get(new Vec(x, y, z)); + } + + public void setTrunk(int x, int y, int z, Role role, Axis axis) { + Vec v = new Vec(x, y, z); + cells.put(v, new Cell(role, axis, false, -1, null)); + trunk.add(v); + leaf.remove(v); + } + + public boolean setLeaf(int x, int y, int z, Role role) { + Vec v = new Vec(x, y, z); + if (cells.containsKey(v)) { + return false; + } + cells.put(v, new Cell(role, Axis.NONE, false, -1, null)); + leaf.add(v); + return true; + } + + public boolean setDecor(int x, int y, int z, int decoratorIndex, String facing) { + Vec v = new Vec(x, y, z); + if (cells.containsKey(v)) { + return false; + } + cells.put(v, new Cell(Role.DECORATOR, Axis.NONE, false, decoratorIndex, facing)); + return true; + } + + public void markExposed(int x, int y, int z) { + Vec v = new Vec(x, y, z); + Cell c = cells.get(v); + if (c != null && !c.exposed()) { + cells.put(v, new Cell(c.role(), c.axis(), true, c.decoratorIndex(), c.facing())); + } + } + + public Map getCells() { + return cells; + } + + public Set getTrunk() { + return trunk; + } + + public Set getLeaf() { + return leaf; + } + + public boolean isEmpty() { + return cells.isEmpty(); + } +} 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 new file mode 100644 index 000000000..19035b812 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java @@ -0,0 +1,176 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.tree; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisMaterialPalette; +import art.arcane.iris.engine.object.IrisProceduralTree; +import art.arcane.iris.engine.object.IrisTreeDecorator; +import art.arcane.iris.util.common.data.B; +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.Axis; +import org.bukkit.Material; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Directional; +import org.bukkit.block.data.Orientable; + +public final class TreeBlockResolver { + private TreeBlockResolver() { + } + + public static BlockData resolve(IrisProceduralTree tree, IrisData data, TreeBlockCanvas.Cell cell, TreeBlockCanvas.Vec pos) { + RNG paletteRng = new RNG(tree.getSeed()); + RNG posRng = new RNG(tree.getSeed() ^ positionHash(pos)); + + switch (cell.role()) { + case TRUNK -> { + BlockData bd = resolveBlock(tree.getTrunk(), tree.getTrunkPalette(), data, pos, paletteRng); + return finishTrunk(bd, cell); + } + case SECONDARY_TRUNK -> { + BlockData bd = resolveBlock(tree.getSecondaryTrunk(), tree.getSecondaryTrunkPalette(), data, pos, paletteRng); + if (bd == null) { + bd = resolveBlock(tree.getTrunk(), tree.getTrunkPalette(), data, pos, paletteRng); + } + return finishTrunk(bd, cell); + } + case LEAF -> { + return resolveBlock(tree.getLeaves(), tree.getLeavesPalette(), data, pos, paletteRng); + } + case SECONDARY_LEAF -> { + BlockData bd = resolveSecondaryLeaf(tree, data, pos, paletteRng, posRng); + if (bd == null) { + bd = resolveBlock(tree.getLeaves(), tree.getLeavesPalette(), data, pos, paletteRng); + } + return bd; + } + case DECORATOR -> { + int index = cell.decoratorIndex(); + if (index < 0 || tree.getDecorators() == null || index >= tree.getDecorators().size()) { + return null; + } + IrisTreeDecorator dec = tree.getDecorators().get(index); + BlockData bd = resolveBlock(dec.getBlock(), dec.getPalette(), data, pos, paletteRng); + if (bd != null && cell.facing() != null && bd instanceof Directional directional) { + try { + directional.setFacing(BlockFace.valueOf(cell.facing().toUpperCase())); + } catch (IllegalArgumentException ignored) { + } + } + return bd; + } + default -> { + return null; + } + } + } + + private static BlockData finishTrunk(BlockData bd, TreeBlockCanvas.Cell cell) { + if (bd == null) { + return null; + } + if (cell.axis() != TreeBlockCanvas.Axis.NONE && bd instanceof Orientable orientable) { + try { + orientable.setAxis(Axis.valueOf(cell.axis().name())); + } catch (IllegalArgumentException ignored) { + } + } + if (cell.exposed()) { + return woodCap(bd); + } + return bd; + } + + private static BlockData resolveSecondaryLeaf(IrisProceduralTree tree, IrisData data, TreeBlockCanvas.Vec pos, RNG paletteRng, RNG posRng) { + if (TreeTrunkBuilder.paletteSet(tree.getSecondaryLeavesPalette())) { + BlockData bd = tree.getSecondaryLeavesPalette().get(paletteRng, pos.x(), pos.y(), pos.z(), data); + return bd == null ? null : bd.clone(); + } + if (tree.getWeightedSecondaryLeaves() != null && !tree.getWeightedSecondaryLeaves().isEmpty()) { + String picked = pickWeighted(tree, posRng); + return picked == null ? null : cloneOrNull(B.getOrNull(picked, false)); + } + if (tree.getSecondaryLeaves() != null && !tree.getSecondaryLeaves().isEmpty()) { + return cloneOrNull(B.getOrNull(tree.getSecondaryLeaves(), false)); + } + return null; + } + + private static String pickWeighted(IrisProceduralTree tree, RNG rng) { + int total = 0; + for (art.arcane.iris.engine.object.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()) { + cumulative += Math.max(0, s.getWeight()); + if (r < cumulative) { + return s.getBlock(); + } + } + return tree.getWeightedSecondaryLeaves().get(tree.getWeightedSecondaryLeaves().size() - 1).getBlock(); + } + + private static BlockData resolveBlock(String block, IrisMaterialPalette palette, IrisData data, TreeBlockCanvas.Vec pos, RNG paletteRng) { + if (TreeTrunkBuilder.paletteSet(palette)) { + BlockData bd = palette.get(paletteRng, pos.x(), pos.y(), pos.z(), data); + return bd == null ? null : bd.clone(); + } + if (block != null && !block.isEmpty()) { + return cloneOrNull(B.getOrNull(block, false)); + } + return null; + } + + private static BlockData woodCap(BlockData bd) { + String name = bd.getMaterial().name(); + String woodName = null; + if (name.endsWith("_LOG")) { + woodName = name.substring(0, name.length() - 4) + "_WOOD"; + } else if (name.endsWith("_STEM")) { + woodName = name.substring(0, name.length() - 5) + "_HYPHAE"; + } + if (woodName == null) { + return bd; + } + try { + Material wood = Material.valueOf(woodName); + BlockData wb = wood.createBlockData(); + if (wb instanceof Orientable wo && bd instanceof Orientable bo) { + wo.setAxis(bo.getAxis()); + } + return wb; + } catch (IllegalArgumentException ignored) { + return bd; + } + } + + private static BlockData cloneOrNull(BlockData bd) { + return bd == null ? null : bd.clone(); + } + + private static long positionHash(TreeBlockCanvas.Vec pos) { + return pos.x() * 73856093L ^ pos.y() * 19349663L ^ pos.z() * 83492791L; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeCanopyBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeCanopyBuilder.java new file mode 100644 index 000000000..6f869f0d7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeCanopyBuilder.java @@ -0,0 +1,291 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.tree; + +import art.arcane.iris.engine.object.IrisProceduralTree; +import art.arcane.iris.engine.object.IrisTreeBranches; +import art.arcane.iris.engine.object.IrisTreeCanopy; +import art.arcane.iris.engine.object.IrisTreeLayer; +import art.arcane.iris.engine.object.IrisTreeLeafMode; +import art.arcane.iris.engine.object.IrisTreeProfile; +import art.arcane.iris.engine.object.IrisTreeSubBranches; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; + +import java.util.List; + +public final class TreeCanopyBuilder { + private TreeCanopyBuilder() { + } + + public static void build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height, double[][] offsets, + long baseSeed, List branchEndpoints) { + IrisTreeCanopy canopy = tree.getCanopy(); + if (canopy == null) { + canopy = new IrisTreeCanopy(); + } + IrisTreeBranches branches = canopy.getBranches(); + + double[][] layers = resolveLayers(canopy, tree.getProfile(), height, branches != null); + + if (branches != null) { + if (layers.length > 0) { + double[][] crown = new double[][]{layers[layers.length - 1]}; + volumeCanopy(canvas, tree, crown, canopy, baseSeed, offsets); + } + branchCanopy(canvas, tree, height, branches, baseSeed, offsets, branchEndpoints); + } else { + volumeCanopy(canvas, tree, layers, canopy, baseSeed, offsets); + } + } + + private static double[][] resolveLayers(IrisTreeCanopy canopy, IrisTreeProfile profile, int height, boolean branchDriven) { + KList explicit = canopy.getLayers(); + if (explicit != null && !explicit.isEmpty()) { + double[][] out = new double[explicit.size()][2]; + for (int i = 0; i < explicit.size(); i++) { + out[i][0] = explicit.get(i).getYOffset(); + out[i][1] = explicit.get(i).getRadius(); + } + return out; + } + return TreeProfiles.presetLayers(profile, height, branchDriven); + } + + private static void volumeCanopy(TreeBlockCanvas canvas, IrisProceduralTree tree, double[][] layers, + IrisTreeCanopy canopy, long baseSeed, double[][] offsets) { + int ocx = 0; + int ocz = 0; + if (offsets != null && offsets.length > 0) { + ocx = (int) Math.round(offsets[offsets.length - 1][0]); + ocz = (int) Math.round(offsets[offsets.length - 1][1]); + } + + double startAngle = canopy.getStartAngle(); + double squish = canopy.getSquish(); + IrisTreeLeafMode mode = canopy.getMode(); + double leafDensity = canopy.getLeafDensity(); + + for (double[] layer : layers) { + int yCenter = (int) Math.round(layer[0]); + double radius = layer[1]; + int halfH = layerHalfHeight(radius, startAngle, squish); + int downH = startAngle < 90.0 + ? (int) Math.ceil(radius * Math.cos(Math.toRadians(startAngle)) * squish) + : 0; + int skirt = downH; + + for (int y = yCenter - skirt; y <= yCenter + halfH; y++) { + int dy = y - yCenter; + double layerR; + if (halfH > 0 && dy >= 0) { + layerR = radius * Math.sqrt(Math.max(0.0, 1.0 - Math.pow(dy / (double) halfH, 2))); + } else if (skirt > 0 && dy < 0) { + layerR = radius * Math.sqrt(Math.max(0.0, 1.0 - Math.pow(dy / (double) skirt, 2))); + } else { + layerR = radius; + } + if (layerR < 0.5) { + continue; + } + placeLeafDisc(canvas, tree, ocx, y, ocz, layerR, mode, leafDensity, baseSeed + y); + } + } + } + + private static int layerHalfHeight(double radius, double startAngle, double squish) { + if (startAngle >= 180.0) { + return 0; + } + return (int) Math.ceil(radius * squish); + } + + private static void placeLeafDisc(TreeBlockCanvas canvas, IrisProceduralTree tree, int cx, int cy, int cz, + double radius, IrisTreeLeafMode mode, double density, long seed) { + RNG rng = new RNG(seed); + int ir = (int) Math.ceil(radius); + for (int dx = -ir; dx <= ir; dx++) { + for (int dz = -ir; dz <= ir; dz++) { + double dist = Math.sqrt(dx * dx + dz * dz); + if (!passesLeafTest(mode, dist, radius, density, rng, cx + dx, cy, cz + dz, seed, Math.abs(dx) == ir && Math.abs(dz) == ir)) { + continue; + } + canvas.setLeaf(cx + dx, cy, cz + dz, resolveLeaf(tree, rng)); + } + } + } + + private static void placeLeafCluster(TreeBlockCanvas canvas, IrisProceduralTree tree, int cx, int cy, int cz, + int radius, IrisTreeLeafMode mode, double density, long seed) { + RNG rng = new RNG(seed); + for (int dx = -radius; dx <= radius; dx++) { + for (int dy = -radius; dy <= radius; dy++) { + for (int dz = -radius; dz <= radius; dz++) { + double dist = Math.sqrt(dx * dx + dy * dy + dz * dz); + boolean corner = Math.abs(dx) == radius && Math.abs(dz) == radius; + if (!passesLeafTest(mode, dist, radius, density, rng, cx + dx, cy + dy, cz + dz, seed, corner)) { + continue; + } + canvas.setLeaf(cx + dx, cy + dy, cz + dz, resolveLeaf(tree, rng)); + } + } + } + } + + private static boolean passesLeafTest(IrisTreeLeafMode mode, double dist, double radius, double density, + RNG rng, int wx, int wy, int wz, long seed, boolean corner) { + switch (mode) { + case TRIMMED -> { + return !(dist > radius || corner); + } + case FILLED -> { + return dist <= radius; + } + case DENSITY -> { + if (dist > radius) { + return false; + } + return rng.nextDouble() <= density * (1.0 - (dist / (radius + 1.0))); + } + case NOISE -> { + if (dist > radius) { + return false; + } + return TreeFunctions.valueNoise3D(wx, wy, wz, seed) <= density; + } + case HOLLOW -> { + return dist <= radius && dist > radius - 1.0; + } + case GRADIENT -> { + if (dist > radius) { + return false; + } + return rng.nextDouble() <= density * Math.pow(1.0 - (dist / (radius + 1.0)), 2); + } + case CLUMPED -> { + if (dist > radius) { + return false; + } + return TreeFunctions.valueNoise3D(Math.floorDiv(wx, 2), Math.floorDiv(wy, 2), Math.floorDiv(wz, 2), seed) <= density; + } + case TATTERED -> { + if (dist > radius || corner) { + return false; + } + return !(dist > radius - 1.5 && rng.nextDouble() < 0.6); + } + case SPARSE -> { + if (dist > radius) { + return false; + } + return rng.nextDouble() <= density * 0.4; + } + default -> { + return dist <= radius; + } + } + } + + private static TreeBlockCanvas.Role resolveLeaf(IrisProceduralTree tree, RNG rng) { + boolean hasSecondary = TreeTrunkBuilder.paletteSet(tree.getSecondaryLeavesPalette()) + || (tree.getWeightedSecondaryLeaves() != null && !tree.getWeightedSecondaryLeaves().isEmpty()) + || (tree.getSecondaryLeaves() != null && !tree.getSecondaryLeaves().isEmpty()); + if (!hasSecondary || tree.getSecondaryLeafFraction() <= 0.0) { + return TreeBlockCanvas.Role.LEAF; + } + if (rng.nextDouble() >= tree.getSecondaryLeafFraction()) { + return TreeBlockCanvas.Role.LEAF; + } + return TreeBlockCanvas.Role.SECONDARY_LEAF; + } + + private static void branchCanopy(TreeBlockCanvas canvas, IrisProceduralTree tree, int height, + IrisTreeBranches branches, long baseSeed, double[][] offsets, + List branchEndpoints) { + long branchSeed = baseSeed + 9999; + RNG branchRng = new RNG(branchSeed); + IrisTreeSubBranches sub = branches.getSubBranches(); + int branchIndex = 0; + + for (int y = 0; y < height; y++) { + double t = y / (double) Math.max(height - 1, 1); + double p = TreeFunctions.branchProbability(branches, t, branchSeed); + if (branchRng.nextDouble() > p) { + continue; + } + + int ox = 0; + int oz = 0; + if (offsets != null && y < offsets.length) { + ox = (int) Math.round(offsets[y][0]); + oz = (int) Math.round(offsets[y][1]); + } + + double az = TreeFunctions.azimuthDegrees(branches.getAzimuthMode(), t, branchIndex, tree, branches.getAzimuth(), branchRng); + branchIndex++; + double branchLen = TreeFunctions.branchLength(branches, t); + double effElevation = branches.isLeafStartUp() ? Math.max(0.0, branches.getElevation()) : branches.getElevation(); + int[] end = branchEndpoint(ox, y, oz, az, effElevation, branchLen); + + rasterizeBranch(canvas, ox, y, oz, end[0], end[1], end[2]); + if (branchEndpoints != null) { + branchEndpoints.add(new int[]{end[0], end[1], end[2], ox, oz}); + } + placeLeafCluster(canvas, tree, end[0], end[1], end[2], branches.getClusterRadius(), + branches.getClusterMode(), branches.getClusterDensity(), branchSeed + y); + + if (sub != null) { + int count = Math.max(1, sub.getCount()); + for (int si = 0; si < count; si++) { + double yawOffset = sub.getYawDelta() * (si - (count - 1) / 2.0); + double subAz = az + yawOffset; + double subEl = effElevation + sub.getPitchDelta(); + double subLen = branchLen * sub.getLengthScale(); + int[] subEnd = branchEndpoint(end[0], end[1], end[2], subAz, subEl, subLen); + rasterizeBranch(canvas, end[0], end[1], end[2], subEnd[0], subEnd[1], subEnd[2]); + placeLeafCluster(canvas, tree, subEnd[0], subEnd[1], subEnd[2], sub.getClusterRadius(), + sub.getClusterMode(), sub.getClusterDensity(), branchSeed + y + si + 1000L); + } + } + } + } + + private static int[] branchEndpoint(int ox, int oy, int oz, double azimuthDeg, double elevationDeg, double length) { + double azRad = Math.toRadians(azimuthDeg); + double elRad = Math.toRadians(elevationDeg); + double dx = length * Math.cos(elRad) * Math.sin(azRad); + double dy = length * Math.sin(elRad); + double dz = length * Math.cos(elRad) * Math.cos(azRad); + return new int[]{ox + (int) Math.round(dx), oy + (int) Math.round(dy), oz + (int) Math.round(dz)}; + } + + private static void rasterizeBranch(TreeBlockCanvas canvas, int ox, int oy, int oz, int ex, int ey, int ez) { + int steps = Math.max(Math.max(Math.abs(ex - ox), Math.abs(ey - oy)), Math.max(Math.abs(ez - oz), 1)); + TreeBlockCanvas.Axis axis = TreeFunctions.logAxis(ex - ox, ey - oy, ez - oz); + for (int i = 0; i <= steps; i++) { + double t = i / (double) steps; + int x = (int) Math.round(ox + (ex - ox) * t); + int y = (int) Math.round(oy + (ey - oy) * t); + int z = (int) Math.round(oz + (ez - oz) * t); + if (!canvas.has(x, y, z)) { + canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, axis); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplier.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplier.java new file mode 100644 index 000000000..a4f34ffa4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplier.java @@ -0,0 +1,280 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.tree; + +import art.arcane.iris.engine.object.IrisProceduralTree; +import art.arcane.iris.engine.object.IrisTreeDecorator; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.math.RNG; + +import java.util.List; +import java.util.Map; + +public final class TreeDecoratorApplier { + private TreeDecoratorApplier() { + } + + public static void apply(TreeBlockCanvas canvas, IrisProceduralTree tree, long baseSeed, List branchEndpoints) { + KList decorators = tree.getDecorators(); + if (decorators == null || decorators.isEmpty()) { + return; + } + RNG rng = new RNG(baseSeed + 77777L); + + for (int idx = 0; idx < decorators.size(); idx++) { + IrisTreeDecorator dec = decorators.get(idx); + boolean hasBlock = (dec.getBlock() != null && !dec.getBlock().isEmpty()) || TreeTrunkBuilder.paletteSet(dec.getPalette()); + if (!hasBlock) { + continue; + } + switch (dec.getTarget()) { + case BRANCH_TIP -> branchTip(canvas, dec, idx, branchEndpoints, rng); + case TRUNK_SURFACE -> trunkSurface(canvas, dec, idx, rng); + case CANOPY_TOP -> canopyTop(canvas, dec, idx, rng); + case CANOPY_BOTTOM -> canopyBottom(canvas, dec, idx, rng); + case TRUNK_BASE -> trunkBase(canvas, dec, idx, rng); + case LEAF_SURFACE -> leafSurface(canvas, dec, idx, rng); + case CANOPY_HANG -> canopyHang(canvas, dec, idx, rng); + case BRANCH_SURFACE -> branchSurface(canvas, dec, idx, rng); + case TRUNK_TOP -> trunkTop(canvas, dec, idx, rng); + case GROUND_SCATTER -> groundScatter(canvas, dec, idx, rng); + } + } + } + + private static void branchTip(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, List branchEndpoints, RNG rng) { + if (branchEndpoints == null) { + return; + } + for (int[] e : branchEndpoints) { + if (rng.nextDouble() > dec.getChance()) { + continue; + } + if (canvas.has(e[0], e[1], e[2])) { + continue; + } + String facing = dec.isAxisAware() ? facingAway(e[3], e[4], e[0], e[2]) : null; + canvas.setDecor(e[0], e[1], e[2], idx, facing); + } + } + + private static void trunkSurface(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { + int[][] sides = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + String[] facings = {"west", "east", "north", "south"}; + for (TreeBlockCanvas.Vec v : new KList<>(canvas.getTrunk())) { + for (int i = 0; i < sides.length; i++) { + int nx = v.x() + sides[i][0]; + int nz = v.z() + sides[i][1]; + if (canvas.has(nx, v.y(), nz) || rng.nextDouble() > dec.getChance()) { + continue; + } + canvas.setDecor(nx, v.y(), nz, idx, dec.isAxisAware() ? facings[i] : null); + } + } + } + + private static void leafSurface(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { + int[][] sides = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + String[] facings = {"west", "east", "north", "south"}; + for (TreeBlockCanvas.Vec v : new KList<>(canvas.getLeaf())) { + for (int i = 0; i < sides.length; i++) { + int nx = v.x() + sides[i][0]; + int nz = v.z() + sides[i][1]; + if (canvas.has(nx, v.y(), nz) || rng.nextDouble() > dec.getChance()) { + continue; + } + canvas.setDecor(nx, v.y(), nz, idx, dec.isAxisAware() ? facings[i] : null); + } + } + } + + private static void branchSurface(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { + for (TreeBlockCanvas.Vec v : new KList<>(canvas.getTrunk())) { + int ay = v.y() + 1; + if (canvas.has(v.x(), ay, v.z()) || rng.nextDouble() > dec.getChance()) { + continue; + } + canvas.setDecor(v.x(), ay, v.z(), idx, null); + } + } + + private static void canopyTop(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { + KMap colTop = new KMap<>(); + for (TreeBlockCanvas.Vec v : canvas.getCells().keySet()) { + long key = pack(v.x(), v.z()); + Integer cur = colTop.get(key); + if (cur == null || v.y() > cur) { + colTop.put(key, v.y()); + } + } + for (Map.Entry e : colTop.entrySet()) { + int x = unpackX(e.getKey()); + int z = unpackZ(e.getKey()); + int aboveY = e.getValue() + 1; + if (canvas.has(x, aboveY, z) || rng.nextDouble() > dec.getChance()) { + continue; + } + canvas.setDecor(x, aboveY, z, idx, null); + } + } + + private static void canopyBottom(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { + KMap colBottom = columnLeafBottoms(canvas); + for (Map.Entry e : colBottom.entrySet()) { + int x = unpackX(e.getKey()); + int z = unpackZ(e.getKey()); + int belowY = e.getValue() - 1; + if (canvas.has(x, belowY, z) || rng.nextDouble() > dec.getChance()) { + continue; + } + canvas.setDecor(x, belowY, z, idx, null); + } + } + + private static void canopyHang(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { + int maxLen = Math.max(1, dec.getLength()); + KMap colBottom = columnLeafBottoms(canvas); + for (Map.Entry e : colBottom.entrySet()) { + if (rng.nextDouble() > dec.getChance()) { + continue; + } + int x = unpackX(e.getKey()); + int z = unpackZ(e.getKey()); + int strand = rng.i(1, maxLen + 1); + for (int k = 1; k <= strand; k++) { + int yy = e.getValue() - k; + if (canvas.has(x, yy, z)) { + break; + } + canvas.setDecor(x, yy, z, idx, null); + } + } + } + + private static void trunkBase(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { + int[][] sides = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; + for (TreeBlockCanvas.Vec v : new KList<>(canvas.getTrunk())) { + if (v.y() != 0) { + continue; + } + for (int[] side : sides) { + int nx = v.x() + side[0]; + int nz = v.z() + side[1]; + if (canvas.has(nx, 0, nz) || rng.nextDouble() > dec.getChance()) { + continue; + } + canvas.setDecor(nx, 0, nz, idx, null); + } + } + } + + private static void trunkTop(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { + int maxY = Integer.MIN_VALUE; + for (TreeBlockCanvas.Vec v : canvas.getTrunk()) { + maxY = Math.max(maxY, v.y()); + } + if (maxY == Integer.MIN_VALUE) { + return; + } + for (TreeBlockCanvas.Vec v : new KList<>(canvas.getTrunk())) { + if (v.y() != maxY) { + continue; + } + int aboveY = v.y() + 1; + if (canvas.has(v.x(), aboveY, v.z()) || rng.nextDouble() > dec.getChance()) { + continue; + } + canvas.setDecor(v.x(), aboveY, v.z(), idx, null); + } + } + + private static void groundScatter(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { + double cx = 0; + double cz = 0; + int count = 0; + double footprint = 1.0; + for (TreeBlockCanvas.Vec v : canvas.getTrunk()) { + if (v.y() == 0) { + cx += v.x(); + cz += v.z(); + count++; + } + } + if (count == 0) { + return; + } + cx /= count; + cz /= count; + for (TreeBlockCanvas.Vec v : canvas.getTrunk()) { + if (v.y() == 0) { + footprint = Math.max(footprint, Math.hypot(v.x() - cx, v.z() - cz)); + } + } + int radius = (int) Math.ceil(footprint + 3.0); + int icx = (int) Math.round(cx); + int icz = (int) Math.round(cz); + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + if (Math.hypot(dx, dz) > radius) { + continue; + } + int x = icx + dx; + int z = icz + dz; + if (canvas.has(x, 0, z) || rng.nextDouble() > dec.getChance()) { + continue; + } + canvas.setDecor(x, 0, z, idx, null); + } + } + } + + private static KMap columnLeafBottoms(TreeBlockCanvas canvas) { + KMap colBottom = new KMap<>(); + for (TreeBlockCanvas.Vec v : canvas.getLeaf()) { + long key = pack(v.x(), v.z()); + Integer cur = colBottom.get(key); + if (cur == null || v.y() < cur) { + colBottom.put(key, v.y()); + } + } + return colBottom; + } + + private static String facingAway(int cx, int cz, int x, int z) { + int dx = x - cx; + int dz = z - cz; + if (Math.abs(dx) >= Math.abs(dz)) { + return dx >= 0 ? "east" : "west"; + } + return dz >= 0 ? "south" : "north"; + } + + private static long pack(int x, int z) { + return (((long) x) << 32) ^ (z & 0xFFFFFFFFL); + } + + private static int unpackX(long key) { + return (int) (key >> 32); + } + + private static int unpackZ(long key) { + return (int) (key & 0xFFFFFFFFL); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeFunctions.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeFunctions.java new file mode 100644 index 000000000..671c36038 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeFunctions.java @@ -0,0 +1,197 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.tree; + +import art.arcane.iris.engine.object.IrisProceduralTree; +import art.arcane.iris.engine.object.IrisTreeAzimuthMode; +import art.arcane.iris.engine.object.IrisTreeBranches; +import art.arcane.iris.engine.object.IrisTreeFunction; +import art.arcane.volmlib.util.math.RNG; + +public final class TreeFunctions { + private static final double GOLDEN_ANGLE = 137.50776405003785; + + private TreeFunctions() { + } + + public static double trunkWidthMultiplier(IrisProceduralTree tree, double t) { + double start = tree.getShapeStart(); + double end = tree.getShapeEnd(); + return switch (tree.getTrunkShape()) { + case CONSTANT -> 1.0; + case LINEAR -> start + (end - start) * t; + case SIGMOID -> 1.0 / (1.0 + Math.exp(tree.getShapeSteepness() * (t - 0.5))); + case LOG -> { + double base = Math.max(1.0001, tree.getShapeBase()); + if (t <= 0.0) { + yield 1.0; + } + yield Math.max(0.0, 1.0 - Math.log(1.0 + t * (base - 1.0)) / Math.log(base)); + } + case SINE -> { + double period = tree.getShapePeriod() == 0 ? 1.0 : tree.getShapePeriod(); + yield 1.0 + tree.getShapeAmplitude() * Math.sin(2.0 * Math.PI * t / period); + } + case PARABOLIC -> { + double peak = tree.getShapePeakOffset(); + double floor = tree.getShapeFloor(); + double denom = Math.pow(Math.max(peak, 1.0 - peak), 2); + if (denom == 0) { + denom = 1; + } + yield floor + (1.0 - floor) * (Math.pow(t - peak, 2) / denom); + } + case EXPONENTIAL -> geometric(start, end, t); + case SQRT -> start + (end - start) * Math.sqrt(t); + case STEP -> t < tree.getShapePeakOffset() ? start : end; + case BELL -> start + (end - start) * bell(t); + case EASE_IN_OUT -> start + (end - start) * smoothstep(t); + }; + } + + public static double curveProgress(IrisTreeFunction fn, double t, double steepness) { + return switch (fn) { + case LINEAR, CONSTANT, SINE -> t; + case LOG -> Math.log(1.0 + t * (Math.E - 1.0)); + case SIGMOID -> 1.0 / (1.0 + Math.exp(-steepness * (t - 0.5))); + case PARABOLIC, EASE_IN_OUT -> smoothstep(t); + case EXPONENTIAL -> t * t; + case SQRT -> Math.sqrt(t); + case STEP -> t < 0.5 ? 0.0 : 1.0; + case BELL -> bell(t); + }; + } + + public static double branchLength(IrisTreeBranches b, double t) { + double base = b.getLengthBase(); + double crown = b.getLengthCrown(); + double max = b.getLengthMax(); + double v = switch (b.getLengthFunction()) { + case CONSTANT -> b.getLengthConstant(); + case LINEAR, SINE -> base + (crown - base) * t; + case SIGMOID -> max / (1.0 + Math.exp(-b.getLengthSteepness() * (t - 0.5))); + case LOG -> max * Math.log(1.0 + t * (Math.E - 1.0)); + case PARABOLIC -> max * (1.0 - Math.pow(2.0 * t - 1.0, 2)); + case EXPONENTIAL -> geometric(base, crown, t); + case SQRT -> base + (crown - base) * Math.sqrt(t); + case STEP -> t < 0.5 ? base : crown; + case BELL -> base + (max - base) * bell(t); + case EASE_IN_OUT -> base + (crown - base) * smoothstep(t); + }; + return Math.max(1.0, v); + } + + public static double branchProbability(IrisTreeBranches b, double t, long seed) { + double std = Math.max(b.getProbabilityStd(), 1e-6); + return switch (b.getProbabilityFunction()) { + case CONSTANT -> b.getProbabilityConstant(); + case LINEAR -> b.getProbabilityBase() + (b.getProbabilityCrown() - b.getProbabilityBase()) * t; + case SIGMOID -> 1.0 / (1.0 + Math.exp(-b.getProbabilitySteepness() * (t - b.getProbabilityMidpoint()))); + case TOP_HEAVY -> Math.pow(t, b.getProbabilityExponent()); + case GAUSSIAN -> Math.exp(-0.5 * Math.pow((t - b.getProbabilityMean()) / std, 2)); + case NOISE -> valueNoise1D(t * b.getProbabilityScale(), seed); + case BOTTOM_HEAVY -> Math.pow(1.0 - t, b.getProbabilityExponent()); + case PERIODIC -> b.getProbabilityConstant() * Math.pow(Math.max(0.0, Math.sin(2.0 * Math.PI * t * b.getProbabilityPeriods())), 8); + case BAND -> Math.abs(t - b.getProbabilityMean()) <= b.getProbabilityStd() ? b.getProbabilityConstant() : 0.0; + case INVERSE_GAUSSIAN -> b.getProbabilityConstant() * (1.0 - Math.exp(-0.5 * Math.pow((t - b.getProbabilityMean()) / std, 2))); + case EXPONENTIAL_DECAY -> b.getProbabilityConstant() * Math.exp(-b.getProbabilityExponent() * t); + }; + } + + public static double azimuthDegrees(IrisTreeAzimuthMode mode, double t, int index, IrisProceduralTree tree, double constantBase, RNG rng) { + return switch (mode) { + case CONSTANT -> constantBase; + case LINEAR -> tree.getAzimuthStart() + (tree.getAzimuthEnd() - tree.getAzimuthStart()) * t; + case SPIRAL -> tree.getAzimuthStart() + tree.getAzimuthTurns() * 360.0 * t; + case SINE -> { + double period = tree.getAzimuthPeriod() == 0 ? 1.0 : tree.getAzimuthPeriod(); + yield tree.getAzimuthOffset() + tree.getAzimuthAmplitude() * Math.sin(2.0 * Math.PI * t / period); + } + case NOISE -> noiseAzimuth(t, tree.getAzimuthScale(), tree.getSeed()); + case RANDOM -> rng != null ? rng.d(0.0, 360.0) : constantBase; + case GOLDEN_ANGLE -> index >= 0 ? constantBase + index * GOLDEN_ANGLE : constantBase; + case ALTERNATING -> index >= 0 ? constantBase + (index % 2) * 180.0 : constantBase; + case WHORL -> { + int count = Math.max(1, tree.getAzimuthWhorlCount()); + yield index >= 0 ? constantBase + (index % count) * (360.0 / count) : constantBase; + } + case ZIGZAG -> index >= 0 ? constantBase + ((index % 2 == 0) ? -tree.getAzimuthAmplitude() : tree.getAzimuthAmplitude()) : constantBase; + }; + } + + public static TreeBlockCanvas.Axis logAxis(double dx, double dy, double dz) { + double ax = Math.abs(dx); + double ay = Math.abs(dy) * 1.8; + double az = Math.abs(dz); + if (ay >= ax && ay >= az) { + return TreeBlockCanvas.Axis.Y; + } + if (ax >= az) { + return TreeBlockCanvas.Axis.X; + } + return TreeBlockCanvas.Axis.Z; + } + + private static double geometric(double a, double b, double t) { + if (a <= 0.0 || b <= 0.0) { + return a + (b - a) * t; + } + return a * Math.pow(b / a, t); + } + + private static double bell(double t) { + return Math.exp(-Math.pow(t - 0.5, 2) / (2.0 * 0.15 * 0.15)); + } + + private static double smoothstep(double t) { + return 3.0 * t * t - 2.0 * t * t * t; + } + + public static double noiseAzimuth(double t, double scale, long seed) { + double a = valueNoise1D(t * scale, seed); + double b = valueNoise1D(t * scale * 0.5, seed + 1); + return ((a + b) / 2.0) * 360.0; + } + + public static double valueNoise1D(double x, long seed) { + long h = mix(seed * 0x9E3779B97F4A7C15L + Math.round(x * 1000.0)); + return unit(h); + } + + public static double valueNoise3D(int x, int y, int z, long seed) { + long h = mix(seed); + h = mix(h + x * 0x9E3779B97F4A7C15L); + h = mix(h + y * 0xC2B2AE3D27D4EB4FL); + h = mix(h + z * 0x165667B19E3779F9L); + return unit(h); + } + + private static long mix(long a) { + a ^= (a >>> 33); + a *= 0xff51afd7ed558ccdL; + a ^= (a >>> 33); + a *= 0xc4ceb9fe1a85ec53L; + a ^= (a >>> 33); + return a; + } + + private static double unit(long h) { + return (h >>> 11) * 0x1.0p-53; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreePlausibility.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreePlausibility.java new file mode 100644 index 000000000..062e44357 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreePlausibility.java @@ -0,0 +1,106 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.tree; + +import art.arcane.iris.engine.object.IrisProceduralTree; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.type.Leaves; + +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public final class TreePlausibility { + private static final int MAX_DISTANCE = 7; + private static final int[][] NEIGHBORS = { + {1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, {0, 0, 1}, {0, 0, -1} + }; + + private TreePlausibility() { + } + + public static void apply(Map resolved, Set trunkPositions, + Set leafPositions, IrisProceduralTree tree) { + Set realLeaves = new HashSet<>(); + for (TreeBlockCanvas.Vec v : leafPositions) { + BlockData bd = resolved.get(v); + if (bd instanceof Leaves) { + realLeaves.add(v); + } + } + if (realLeaves.isEmpty()) { + return; + } + + Map distance = computeDistances(trunkPositions, realLeaves); + + for (TreeBlockCanvas.Vec v : realLeaves) { + Leaves leaf = (Leaves) resolved.get(v); + Integer d = distance.get(v); + if (!tree.isPlausible()) { + leaf.setPersistent(true); + leaf.setDistance(1); + } else if (d != null && d < MAX_DISTANCE) { + leaf.setPersistent(false); + leaf.setDistance(Math.max(1, d)); + } else { + leaf.setPersistent(true); + leaf.setDistance(MAX_DISTANCE); + } + } + } + + static Map computeDistances(Set trunkPositions, Set realLeaves) { + Map distance = new HashMap<>(); + ArrayDeque queue = new ArrayDeque<>(); + + for (TreeBlockCanvas.Vec v : realLeaves) { + for (int[] n : NEIGHBORS) { + if (trunkPositions.contains(new TreeBlockCanvas.Vec(v.x() + n[0], v.y() + n[1], v.z() + n[2]))) { + distance.put(v, 1); + queue.add(v); + break; + } + } + } + + while (!queue.isEmpty()) { + TreeBlockCanvas.Vec v = queue.poll(); + int d = distance.get(v); + if (d >= MAX_DISTANCE) { + continue; + } + for (int[] n : NEIGHBORS) { + TreeBlockCanvas.Vec nv = new TreeBlockCanvas.Vec(v.x() + n[0], v.y() + n[1], v.z() + n[2]); + if (!realLeaves.contains(nv)) { + continue; + } + Integer existing = distance.get(nv); + if (existing == null || existing > d + 1) { + distance.put(nv, d + 1); + queue.add(nv); + } + } + } + + return distance; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeProfiles.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeProfiles.java new file mode 100644 index 000000000..1e31797f9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeProfiles.java @@ -0,0 +1,79 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.tree; + +import art.arcane.iris.engine.object.IrisTreeProfile; + +public final class TreeProfiles { + private TreeProfiles() { + } + + static double radiusScale(IrisTreeProfile profile) { + return switch (profile) { + case OAK, DARK_OAK, CHERRY -> 1.0; + case BIRCH -> 0.7; + case SPRUCE -> 0.55; + case JUNGLE -> 0.45; + case ACACIA -> 0.6; + case DARK_OAK_FLAT -> 1.25; + case DARK_OAK_FLAT_WIDE -> 1.8; + case PALM -> 0.5; + case WILLOW -> 1.1; + case COLUMNAR -> 0.35; + case BUSH -> 1.3; + case MEGA_SPRUCE -> 0.7; + }; + } + + static double[][] fractions(IrisTreeProfile profile) { + return switch (profile) { + case OAK -> new double[][]{{0.55, 0.6}, {0.65, 0.9}, {0.75, 1.0}, {0.85, 0.9}, {0.92, 0.65}, {0.98, 0.35}}; + case BIRCH -> new double[][]{{0.6, 0.45}, {0.7, 0.75}, {0.8, 0.85}, {0.88, 0.75}, {0.94, 0.45}, {0.99, 0.2}}; + case SPRUCE, MEGA_SPRUCE -> new double[][]{{0.3, 1.0}, {0.42, 0.85}, {0.54, 0.7}, {0.65, 0.55}, {0.75, 0.4}, {0.84, 0.25}, {0.91, 0.15}, {0.97, 0.05}}; + case JUNGLE -> new double[][]{{0.82, 0.4}, {0.88, 0.8}, {0.93, 1.0}, {0.97, 0.7}, {1.0, 0.3}}; + case ACACIA -> new double[][]{{0.9, 0.5}, {0.95, 0.8}, {0.99, 0.4}}; + case DARK_OAK -> new double[][]{{0.5, 0.7}, {0.62, 1.0}, {0.72, 1.0}, {0.82, 0.9}, {0.91, 0.6}, {0.97, 0.3}}; + case DARK_OAK_FLAT -> new double[][]{{0.74, 0.62}, {0.83, 1.0}, {0.92, 1.0}, {1.0, 0.6}}; + case DARK_OAK_FLAT_WIDE -> new double[][]{{0.74, 0.62}, {0.83, 1.0}, {0.92, 1.0}, {1.0, 0.62}}; + case CHERRY -> new double[][]{{0.5, 0.5}, {0.62, 0.9}, {0.74, 1.0}, {0.84, 1.0}, {0.92, 0.7}, {0.98, 0.4}}; + case PALM -> new double[][]{{0.92, 0.45}, {0.97, 0.9}, {1.0, 1.0}, {1.03, 0.6}}; + case WILLOW -> new double[][]{{0.55, 0.7}, {0.68, 1.0}, {0.8, 1.0}, {0.9, 0.85}, {0.97, 0.6}}; + case COLUMNAR -> new double[][]{{0.4, 0.6}, {0.55, 0.9}, {0.7, 1.0}, {0.82, 0.9}, {0.92, 0.7}, {0.98, 0.4}}; + case BUSH -> new double[][]{{0.25, 0.7}, {0.45, 1.0}, {0.65, 1.0}, {0.82, 0.8}, {0.95, 0.5}}; + }; + } + + static double[][] presetLayers(IrisTreeProfile profile, int height, boolean branchDriven) { + double scale = radiusScale(profile); + int baseRadius = Math.max(3, (int) Math.round(height / 2.0 * scale)); + double[][] fractions = fractions(profile); + + int crownExtra = branchDriven ? 0 : Math.max(2, height / 4); + int yTop = (height - 1) + crownExtra; + + double[][] layers = new double[fractions.length][2]; + for (int i = 0; i < fractions.length; i++) { + int yOff = (int) Math.round(fractions[i][0] * yTop); + double radius = Math.max(1.5, baseRadius * fractions[i][1]); + layers[i][0] = yOff; + layers[i][1] = radius; + } + return layers; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeRootBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeRootBuilder.java new file mode 100644 index 000000000..287658a19 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeRootBuilder.java @@ -0,0 +1,106 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.tree; + +import art.arcane.iris.engine.object.IrisProceduralTree; +import art.arcane.volmlib.util.math.RNG; + +import java.util.ArrayList; +import java.util.List; + +public final class TreeRootBuilder { + private TreeRootBuilder() { + } + + public static void build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height, long baseSeed) { + List cells = new ArrayList<>(); + for (TreeBlockCanvas.Vec v : canvas.getTrunk()) { + if (v.y() == 0) { + cells.add(new int[]{v.x(), v.z()}); + } + } + if (cells.isEmpty()) { + return; + } + + int depth = rootDepth(height); + RNG rng = new RNG(baseSeed ^ 0x5009L); + + double cx = 0; + double cz = 0; + for (int[] c : cells) { + cx += c[0]; + cz += c[1]; + } + cx /= cells.size(); + cz /= cells.size(); + + double baseRadius = 1.0; + for (int[] c : cells) { + baseRadius = Math.max(baseRadius, Math.hypot(c[0] - cx, c[1] - cz) + 0.5); + } + + int icx = (int) Math.round(cx); + int icz = (int) Math.round(cz); + + for (int k = 1; k <= depth; k++) { + double frac = k / (double) depth; + double keepR = baseRadius * (1.0 - 0.6 * frac); + for (int[] c : cells) { + if (Math.hypot(c[0] - cx, c[1] - cz) <= keepR + 1e-6) { + placeRoot(canvas, c[0], -k, c[1]); + } + } + placeRoot(canvas, icx, -k, icz); + } + + int nLegs = Math.max(4, Math.min(12, cells.size() + 2)); + int legLen = depth; + double flare = baseRadius + Math.max(2.0, depth * 0.6); + for (int li = 0; li < nLegs; li++) { + double ang = 2.0 * Math.PI * li / nLegs + rng.d(-0.25, 0.25); + double ca = Math.cos(ang); + double sa = Math.sin(ang); + int prevX = (int) Math.round(cx + baseRadius * ca); + int prevZ = (int) Math.round(cz + baseRadius * sa); + for (int k = 1; k <= legLen; k++) { + double frac = k / (double) legLen; + double r = baseRadius + (flare - baseRadius) * frac; + int x = (int) Math.round(cx + r * ca); + int z = (int) Math.round(cz + r * sa); + placeRoot(canvas, x, -k, z); + if (x != prevX || z != prevZ) { + placeRoot(canvas, prevX, -k, prevZ); + } + prevX = x; + prevZ = z; + } + } + } + + private static void placeRoot(TreeBlockCanvas canvas, int x, int y, int z) { + if (!canvas.has(x, y, z)) { + canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, TreeBlockCanvas.Axis.Y); + } + } + + private static int rootDepth(int height) { + return Math.max(2, Math.min(16, (int) Math.round(0.18 * height))); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java new file mode 100644 index 000000000..4f33e75b0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java @@ -0,0 +1,164 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object.tree; + +import art.arcane.iris.engine.object.IrisMaterialPalette; +import art.arcane.iris.engine.object.IrisProceduralTree; +import art.arcane.iris.engine.object.IrisTreeAzimuthMode; + +import java.util.ArrayList; +import java.util.List; + +public final class TreeTrunkBuilder { + private TreeTrunkBuilder() { + } + + public static double[][] build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height) { + boolean hasSecondary = hasSecondaryTrunk(tree); + + double[][] offsets = new double[height][2]; + double prevCx = 0.0; + double prevCz = 0.0; + + for (int y = 0; y < height; y++) { + double[] lean = leanOffset(tree, y, height); + double cx = lean[0]; + double cz = lean[1]; + offsets[y][0] = cx; + offsets[y][1] = cz; + + int w = widthAt(tree, y, height); + TreeBlockCanvas.Axis axis = TreeFunctions.logAxis(cx - prevCx, 1.0, cz - prevCz); + TreeBlockCanvas.Role role = trunkRole(tree, hasSecondary, y, height); + + for (int[] xz : squarePositions(cx, cz, w)) { + canvas.setTrunk(xz[0], y, xz[1], role, axis); + } + + if (y > 0) { + double shift = Math.hypot(cx - prevCx, cz - prevCz); + if (shift > 1.0) { + int steps = (int) Math.ceil(shift); + for (int s = 1; s < steps; s++) { + double tFill = s / (double) steps; + double icx = prevCx + (cx - prevCx) * tFill; + double icz = prevCz + (cz - prevCz) * tFill; + int fillY = (int) Math.round(y - 1 + tFill); + for (int[] xz : squarePositions(icx, icz, w)) { + if (!canvas.has(xz[0], fillY, xz[1])) { + canvas.setTrunk(xz[0], fillY, xz[1], role, axis); + } + } + } + } + } + + prevCx = cx; + prevCz = cz; + } + + markExposedEnds(canvas); + return offsets; + } + + private static void markExposedEnds(TreeBlockCanvas canvas) { + List trunkPositions = new ArrayList<>(canvas.getTrunk()); + for (TreeBlockCanvas.Vec v : trunkPositions) { + if (!canvas.has(v.x(), v.y() + 1, v.z()) || !canvas.has(v.x(), v.y() - 1, v.z())) { + canvas.markExposed(v.x(), v.y(), v.z()); + } + } + } + + private static int widthAt(IrisProceduralTree tree, int y, int height) { + double t = y / (double) Math.max(height - 1, 1); + double multiplier = TreeFunctions.trunkWidthMultiplier(tree, t); + return Math.max(1, (int) Math.round(tree.getTrunkWidth() * multiplier)); + } + + private static TreeBlockCanvas.Role trunkRole(IrisProceduralTree tree, boolean hasSecondary, int y, int height) { + if (!hasSecondary) { + return TreeBlockCanvas.Role.TRUNK; + } + double t = y / (double) Math.max(height - 1, 1); + if (tree.getSecondaryTrunkStart() <= t && t <= tree.getSecondaryTrunkEnd()) { + return TreeBlockCanvas.Role.SECONDARY_TRUNK; + } + return TreeBlockCanvas.Role.TRUNK; + } + + private static boolean hasSecondaryTrunk(IrisProceduralTree tree) { + if (paletteSet(tree.getSecondaryTrunkPalette())) { + return true; + } + return tree.getSecondaryTrunk() != null && !tree.getSecondaryTrunk().isEmpty(); + } + + static boolean paletteSet(IrisMaterialPalette palette) { + return palette != null && palette.getPalette() != null && !palette.getPalette().isEmpty(); + } + + private static double[] leanOffset(IrisProceduralTree tree, int y, int height) { + if (tree.getLeanAngle() == 0.0 || height <= 1) { + return new double[]{0.0, 0.0}; + } + double t = y / (double) Math.max(height - 1, 1); + double progress = TreeFunctions.curveProgress(tree.getTrunkCurve(), t, tree.getCurveSteepness()); + double maxReach = height * Math.tan(Math.toRadians(tree.getLeanAngle())); + double reach = maxReach * progress; + double azDeg = tree.getLeanAzimuthMode() == IrisTreeAzimuthMode.CONSTANT + ? tree.getLeanAzimuth() + : TreeFunctions.azimuthDegrees(tree.getLeanAzimuthMode(), t, -1, tree, tree.getLeanAzimuth(), null); + double azRad = Math.toRadians(azDeg); + double dx = reach * Math.sin(azRad); + double dz = reach * Math.cos(azRad); + return new double[]{dx, dz}; + } + + private static List squarePositions(double cx, double cz, int width) { + List out = new ArrayList<>(); + if (width % 2 == 1) { + int half = width / 2; + int icx = (int) Math.round(cx); + int icz = (int) Math.round(cz); + for (int dx = -half; dx <= half; dx++) { + for (int dz = -half; dz <= half; dz++) { + out.add(new int[]{icx + dx, icz + dz}); + } + } + } else { + int ox = (int) Math.floor(cx) - width / 2 + 1; + int oz = (int) Math.floor(cz) - width / 2 + 1; + for (int dx = 0; dx < width; dx++) { + for (int dz = 0; dz < width; dz++) { + out.add(new int[]{ox + dx, oz + dz}); + } + } + } + return out; + } + + static String stripState(String block) { + if (block == null) { + return null; + } + int idx = block.indexOf('['); + return idx < 0 ? block : block.substring(0, idx); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java b/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java new file mode 100644 index 000000000..7bb9e7b01 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java @@ -0,0 +1,191 @@ +package art.arcane.iris.engine.object.tree; + +import art.arcane.iris.engine.object.IrisProceduralTree; +import art.arcane.iris.engine.object.IrisTreeBranchProbability; +import art.arcane.iris.engine.object.IrisTreeBranches; +import art.arcane.iris.engine.object.IrisTreeFunction; +import art.arcane.iris.engine.object.IrisTreeLeafMode; +import art.arcane.iris.engine.object.IrisTreeProfile; +import org.junit.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ProceduralTreeGeneratorTest { + + @Test + public void trunkBuildsConnectedColumnOfRequestedHeight() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setTrunk("minecraft:oak_log"); + tree.setTrunkWidth(1); + + TreeBlockCanvas canvas = new TreeBlockCanvas(); + int height = 12; + TreeTrunkBuilder.build(canvas, tree, height); + + for (int y = 0; y < height; y++) { + assertTrue("trunk column missing at y=" + y, canvas.has(0, y, 0)); + } + assertEquals(height, canvas.getTrunk().size()); + } + + @Test + public void leaningTrunkStaysConnected() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setTrunk("minecraft:oak_log"); + tree.setLeanAngle(35); + tree.setLeanAzimuth(45); + + TreeBlockCanvas canvas = new TreeBlockCanvas(); + int height = 16; + TreeTrunkBuilder.build(canvas, tree, height); + + for (int y = 0; y < height; y++) { + boolean layerHasBlock = false; + for (TreeBlockCanvas.Vec v : canvas.getTrunk()) { + if (v.y() == y) { + layerHasBlock = true; + break; + } + } + assertTrue("leaning trunk has a gap at y=" + y, layerHasBlock); + } + } + + @Test + public void volumeCanopyProducesLeavesAboveTrunk() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setTrunk("minecraft:oak_log"); + tree.setLeaves("minecraft:oak_leaves"); + tree.setProfile(IrisTreeProfile.OAK); + + TreeBlockCanvas canvas = new TreeBlockCanvas(); + int height = 9; + double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height); + TreeCanopyBuilder.build(canvas, tree, height, offsets, 1234L, null); + + assertFalse("canopy produced no leaves", canvas.getLeaf().isEmpty()); + + int maxLeafY = Integer.MIN_VALUE; + for (TreeBlockCanvas.Vec v : canvas.getLeaf()) { + maxLeafY = Math.max(maxLeafY, v.y()); + } + assertTrue("leaves should rise above the trunk top", maxLeafY >= height - 1); + } + + @Test + public void hollowCanopyHasFewerLeavesThanFilled() { + TreeBlockCanvas filled = canopyOnly(IrisTreeLeafMode.FILLED); + TreeBlockCanvas hollow = canopyOnly(IrisTreeLeafMode.HOLLOW); + assertTrue("hollow canopy should place fewer leaves than filled", + hollow.getLeaf().size() < filled.getLeaf().size()); + assertFalse("hollow canopy still places a leaf shell", hollow.getLeaf().isEmpty()); + } + + private TreeBlockCanvas canopyOnly(IrisTreeLeafMode mode) { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setTrunk("minecraft:oak_log"); + tree.setLeaves("minecraft:oak_leaves"); + tree.setProfile(IrisTreeProfile.OAK); + tree.getCanopy().setMode(mode); + TreeBlockCanvas canvas = new TreeBlockCanvas(); + int height = 10; + double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height); + TreeCanopyBuilder.build(canvas, tree, height, offsets, 99L, null); + return canvas; + } + + @Test + public void branchCanopyRecordsEndpoints() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setTrunk("minecraft:jungle_log"); + tree.setLeaves("minecraft:jungle_leaves"); + tree.setProfile(IrisTreeProfile.JUNGLE); + IrisTreeBranches branches = new IrisTreeBranches(); + branches.setProbabilityFunction(IrisTreeBranchProbability.CONSTANT); + branches.setProbabilityConstant(1.0); + branches.setLengthFunction(IrisTreeFunction.CONSTANT); + branches.setLengthConstant(4); + branches.setAzimuthMode(art.arcane.iris.engine.object.IrisTreeAzimuthMode.GOLDEN_ANGLE); + tree.getCanopy().setBranches(branches); + + TreeBlockCanvas canvas = new TreeBlockCanvas(); + int height = 14; + double[][] offsets = TreeTrunkBuilder.build(canvas, tree, height); + List endpoints = new java.util.ArrayList<>(); + TreeCanopyBuilder.build(canvas, tree, height, offsets, 777L, endpoints); + + assertFalse("branch system recorded no endpoints", endpoints.isEmpty()); + assertFalse("branch system placed no leaves", canvas.getLeaf().isEmpty()); + } + + @Test + public void rootsExtendBelowTheBase() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setTrunk("minecraft:oak_log"); + + TreeBlockCanvas canvas = new TreeBlockCanvas(); + int height = 14; + TreeTrunkBuilder.build(canvas, tree, height); + TreeRootBuilder.build(canvas, tree, height, 42L); + + int minY = Integer.MAX_VALUE; + for (TreeBlockCanvas.Vec v : canvas.getTrunk()) { + minY = Math.min(minY, v.y()); + } + assertTrue("roots should descend below the trunk base", minY < 0); + } + + @Test + public void plausibilityDistancesPropagateFromWood() { + Set trunk = new HashSet<>(); + trunk.add(new TreeBlockCanvas.Vec(0, 0, 0)); + trunk.add(new TreeBlockCanvas.Vec(0, 1, 0)); + + TreeBlockCanvas.Vec a = new TreeBlockCanvas.Vec(1, 1, 0); + TreeBlockCanvas.Vec b = new TreeBlockCanvas.Vec(2, 1, 0); + TreeBlockCanvas.Vec c = new TreeBlockCanvas.Vec(3, 1, 0); + TreeBlockCanvas.Vec far = new TreeBlockCanvas.Vec(40, 1, 0); + Set realLeaves = new HashSet<>(); + realLeaves.add(a); + realLeaves.add(b); + realLeaves.add(c); + realLeaves.add(far); + + Map distances = TreePlausibility.computeDistances(trunk, realLeaves); + + assertEquals(Integer.valueOf(1), distances.get(a)); + assertEquals(Integer.valueOf(2), distances.get(b)); + assertEquals(Integer.valueOf(3), distances.get(c)); + assertNull("disconnected leaf must remain unsupported", distances.get(far)); + } + + @Test + public void plausibilityDistanceCapsAtSeven() { + Set trunk = new HashSet<>(); + trunk.add(new TreeBlockCanvas.Vec(0, 0, 0)); + + Set realLeaves = new HashSet<>(); + for (int x = 1; x <= 12; x++) { + realLeaves.add(new TreeBlockCanvas.Vec(x, 0, 0)); + } + + Map distances = TreePlausibility.computeDistances(trunk, realLeaves); + + for (int x = 1; x <= 7; x++) { + assertEquals("leaf at distance " + x + " should be reachable", + Integer.valueOf(x), distances.get(new TreeBlockCanvas.Vec(x, 0, 0))); + } + for (int x = 8; x <= 12; x++) { + assertNull("leaves beyond distance 7 must remain unsupported", + distances.get(new TreeBlockCanvas.Vec(x, 0, 0))); + } + } +}